Skip to main content

lean_ctx/core/
shell_file_effects.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Which files a shell command reads or rewrites, for bounce accounting.
4//!
5//! The bounce tracker books a correction when a lossy compressed view of a
6//! file is followed by its full content, and cancels it when the file is
7//! edited right after (read-before-edit). Agents read and edit through the
8//! shell as well, so `ctx_shell` commands are classified here:
9//!
10//! * **full reads** — `cat`, `bat`/`batcat` (without `-r`), `less`, `more`
11//!   whose output reaches the model unfiltered;
12//! * **partial reads** — `head`, `tail`, `sed` without `-i`, `bat -r`, or a
13//!   full reader whose output is piped onward: they never start or complete
14//!   a correction;
15//! * **edits** — `sed -i`, `perl -i`, output redirections (`>`, `>>`, `>|`,
16//!   `N>`, `&>`), `tee`, `cp`/`mv`/`install` onto a path, and `apply_patch`
17//!   patch bodies (`*** Update File:` …).
18//!
19//! The parse is deliberately conservative: duplications (`2>&1`, `>&2`),
20//! `/dev/*` targets and targets built from expansions (`$VAR`, `` `cmd` ``)
21//! are never edits, and anything not recognised is ignored rather than
22//! guessed. A missed edit leaves today's behaviour; a wrongly reported edit
23//! only cancels a provisional correction.
24
25use crate::core::shell_allowlist::{Separator, segments_with_separators, strip_all_heredoc_bodies};
26
27/// How much of a file a shell read delivered.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ShellReadKind {
30    /// The whole file, unfiltered.
31    Full,
32    /// A window or filtered slice of it.
33    Partial,
34}
35
36/// The files one shell command reads and rewrites, paths as written
37/// (relative paths joined onto a preceding `cd DIR`).
38#[derive(Debug, Default, Clone, PartialEq, Eq)]
39pub struct ShellFileEffects {
40    pub reads: Vec<(String, ShellReadKind)>,
41    pub edits: Vec<String>,
42}
43
44/// Nested `sh -c`/`lean-ctx -c` levels followed; deeper ones are not read.
45const MAX_NESTED_SHELLS: usize = 16;
46
47/// Classify the file reads and edits of `command`. A command longer than the
48/// shell gate validates is not analysed: the hooks run this before the gate,
49/// and each nested `sh -c` level is another recursive pass.
50#[must_use]
51pub fn analyze(command: &str) -> ShellFileEffects {
52    if command.len() > crate::core::shell_allowlist::MAX_VALIDATED_COMMAND_BYTES {
53        return ShellFileEffects::default();
54    }
55    analyze_nested(command, 0)
56}
57
58fn analyze_nested(command: &str, depth: usize) -> ShellFileEffects {
59    let mut effects = ShellFileEffects::default();
60    // Patch bodies live inside heredocs or quoted arguments; scan them before
61    // the heredoc bodies are stripped for segmenting.
62    for path in apply_patch_targets(command) {
63        push_unique(&mut effects.edits, path);
64    }
65
66    let stripped = strip_all_heredoc_bodies(command);
67    let segments = segments_with_separators(&stripped);
68    let mut cwd: Option<String> = None;
69    for (segment, sep) in &segments {
70        // A reader whose output feeds another command delivers a filtered
71        // slice, not the file.
72        let piped_out = *sep == Some(Separator::Pipe);
73
74        let words = lex(segment);
75        let mut argv: Vec<&Word> = Vec::new();
76        let mut redirected_out = false;
77        let mut iter = words.iter().peekable();
78        while let Some(word) = iter.next() {
79            match word {
80                Word::Redirect { output, dup } => {
81                    let target = iter.next();
82                    if !*output {
83                        continue;
84                    }
85                    redirected_out = true;
86                    if *dup {
87                        continue;
88                    }
89                    if let Some(Word::Text { text, plain_path }) = target
90                        && *plain_path
91                        && is_edit_target(text)
92                    {
93                        push_unique(&mut effects.edits, join(cwd.as_deref(), text));
94                    }
95                }
96                Word::Text { .. } => argv.push(word),
97            }
98        }
99
100        // Leading `NAME=value` assignments only set the environment.
101        let argv: Vec<&str> = argv
102            .iter()
103            .filter_map(|w| match w {
104                Word::Text { text, .. } => Some(text.as_str()),
105                Word::Redirect { .. } => None,
106            })
107            .skip_while(|w| is_env_assignment(w))
108            .collect();
109        let Some((&bin, args)) = argv.split_first() else {
110            continue;
111        };
112        let bin = bin.rsplit('/').next().unwrap_or(bin);
113
114        match bin {
115            // `lean-ctx -c 'CMD'` (the hook rewrite) and `sh -c 'CMD'` run CMD.
116            "lean-ctx" | "sh" | "bash" | "zsh" | "dash" => {
117                if let [flag, inner, ..] = args
118                    && *flag == "-c"
119                    && depth < MAX_NESTED_SHELLS
120                {
121                    let nested = analyze_nested(inner, depth + 1);
122                    for (path, kind) in nested.reads {
123                        push_read(&mut effects.reads, join(cwd.as_deref(), &path), kind);
124                    }
125                    for path in nested.edits {
126                        push_unique(&mut effects.edits, join(cwd.as_deref(), &path));
127                    }
128                }
129            }
130            "cd" => {
131                // Only a plain `cd DIR` moves later relative paths.
132                if let [dir] = args
133                    && !dir.starts_with('-')
134                    && is_plain(dir)
135                {
136                    cwd = Some(join(cwd.as_deref(), dir));
137                }
138            }
139            "cat" | "less" | "more" | "bat" | "batcat" => {
140                if redirected_out {
141                    // `cat a > b` copies; nothing reaches the model.
142                    continue;
143                }
144                let (files, ranged) = reader_files(bin, args);
145                let kind = if ranged || piped_out {
146                    ShellReadKind::Partial
147                } else {
148                    ShellReadKind::Full
149                };
150                for file in files {
151                    push_read(&mut effects.reads, join(cwd.as_deref(), file), kind);
152                }
153            }
154            "head" | "tail" => {
155                for file in head_tail_files(args) {
156                    push_read(
157                        &mut effects.reads,
158                        join(cwd.as_deref(), file),
159                        ShellReadKind::Partial,
160                    );
161                }
162            }
163            "sed" | "perl" => {
164                let (in_place, files) = script_tool_files(bin, args);
165                for file in files {
166                    let path = join(cwd.as_deref(), file);
167                    if in_place {
168                        push_unique(&mut effects.edits, path);
169                    } else if bin == "sed" && !redirected_out {
170                        push_read(&mut effects.reads, path, ShellReadKind::Partial);
171                    }
172                }
173            }
174            "tee" => {
175                for file in positional(args, &[]) {
176                    if is_edit_target(file) {
177                        push_unique(&mut effects.edits, join(cwd.as_deref(), file));
178                    }
179                }
180            }
181            "cp" | "mv" | "install" => {
182                for target in copy_targets(args) {
183                    push_unique(&mut effects.edits, join(cwd.as_deref(), &target));
184                }
185            }
186            _ => {}
187        }
188    }
189    effects
190}
191
192/// Paths an `apply_patch` body touches: `*** Update File:`, `*** Add File:`,
193/// `*** Delete File:` and `*** Move to:` headers. Shared with the host-edit
194/// hook, which receives Codex `apply_patch` calls as raw patch text.
195#[must_use]
196pub fn apply_patch_targets(text: &str) -> Vec<String> {
197    if !text.contains("*** Begin Patch") {
198        return Vec::new();
199    }
200    let mut paths = Vec::new();
201    for line in text.lines() {
202        let line = line.trim();
203        let path = [
204            "*** Update File:",
205            "*** Add File:",
206            "*** Delete File:",
207            "*** Move to:",
208        ]
209        .iter()
210        .find_map(|prefix| line.strip_prefix(prefix));
211        if let Some(path) = path.map(str::trim).filter(|p| !p.is_empty()) {
212            push_unique(&mut paths, path.to_string());
213        }
214    }
215    paths
216}
217
218fn push_unique(list: &mut Vec<String>, path: String) {
219    if !list.contains(&path) {
220        list.push(path);
221    }
222}
223
224fn push_read(reads: &mut Vec<(String, ShellReadKind)>, path: String, kind: ShellReadKind) {
225    if path.starts_with("/dev/") || path == "-" {
226        return;
227    }
228    if !reads.iter().any(|(p, k)| *p == path && *k == kind) {
229        reads.push((path, kind));
230    }
231}
232
233fn join(cwd: Option<&str>, path: &str) -> String {
234    match cwd {
235        Some(dir) if !path.starts_with('/') && !path.starts_with('~') => {
236            format!("{}/{path}", dir.trim_end_matches('/'))
237        }
238        _ => path.to_string(),
239    }
240}
241
242/// A word that is a literal path: no expansion, glob or substitution.
243fn is_plain(word: &str) -> bool {
244    !word.is_empty() && !word.contains(['$', '`', '*', '?', '[', '{', '(', ')', '<', '>'])
245}
246
247fn is_edit_target(word: &str) -> bool {
248    is_plain(word) && word != "-" && !word.starts_with("/dev/") && !word.starts_with('-')
249}
250
251fn is_env_assignment(word: &str) -> bool {
252    word.split_once('=').is_some_and(|(name, _)| {
253        !name.is_empty()
254            && !name.starts_with(|c: char| c.is_ascii_digit())
255            && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
256    })
257}
258
259/// Positional arguments: everything that is not a flag, minus the values of
260/// `takes_value` flags; `--` ends option parsing.
261fn positional<'a>(args: &[&'a str], takes_value: &[&str]) -> Vec<&'a str> {
262    let mut out = Vec::new();
263    let mut iter = args.iter();
264    while let Some(&arg) = iter.next() {
265        if arg == "--" {
266            out.extend(iter.copied());
267            break;
268        }
269        if arg.starts_with('-') && arg.len() > 1 {
270            if takes_value.contains(&arg) {
271                iter.next();
272            }
273            continue;
274        }
275        out.push(arg);
276    }
277    out
278}
279
280/// Files of a full-content reader and whether the read is a range.
281fn reader_files<'a>(bin: &str, args: &[&'a str]) -> (Vec<&'a str>, bool) {
282    match bin {
283        "bat" | "batcat" => {
284            const VALUE_FLAGS: &[&str] = &[
285                "-l",
286                "--language",
287                "-r",
288                "--line-range",
289                "-H",
290                "--highlight-line",
291                "-m",
292                "--map-syntax",
293                "--style",
294                "--theme",
295                "--tabs",
296                "--wrap",
297                "--terminal-width",
298                "--color",
299                "--decorations",
300                "--paging",
301                "--italic-text",
302                "--file-name",
303                "--diff-context",
304            ];
305            let ranged = args
306                .iter()
307                .any(|a| *a == "-r" || *a == "--line-range" || a.starts_with("--line-range="));
308            (positional(args, VALUE_FLAGS), ranged)
309        }
310        // `less +N` / `less -pPATTERN` are positioning options, not ranges:
311        // the pager still holds the whole file.
312        "less" | "more" => (
313            args.iter()
314                .copied()
315                .filter(|a| !a.starts_with('-') && !a.starts_with('+'))
316                .collect(),
317            false,
318        ),
319        _ => (positional(args, &[]), false),
320    }
321}
322
323fn head_tail_files<'a>(args: &[&'a str]) -> Vec<&'a str> {
324    positional(args, &["-n", "-c", "--lines", "--bytes"])
325        .into_iter()
326        .filter(|a| !a.starts_with('+'))
327        .collect()
328}
329
330/// `sed`/`perl`: whether the run edits in place, and its file operands.
331///
332/// A short-flag cluster containing `i` is in-place (GNU/BSD `sed -i[SUFFIX]`,
333/// `perl -pi`); characters after the `i` are its backup suffix. `-e`/`-f`
334/// (`sed`) and `-e`/`-E` (`perl`) carry the script; without one the first
335/// operand is the script (`sed`) or program file (`perl`).
336fn script_tool_files<'a>(bin: &str, args: &[&'a str]) -> (bool, Vec<&'a str>) {
337    let script_flags: &[char] = if bin == "perl" {
338        &['e', 'E']
339    } else {
340        &['e', 'f']
341    };
342    let mut in_place = false;
343    let mut has_script = false;
344    let mut operands = Vec::new();
345    let mut iter = args.iter();
346    while let Some(&arg) = iter.next() {
347        if arg == "--" {
348            operands.extend(iter.copied());
349            break;
350        }
351        if let Some(long) = arg.strip_prefix("--") {
352            if long == "in-place" || long.starts_with("in-place=") {
353                in_place = true;
354            } else if long == "expression" || long == "file" {
355                has_script = true;
356                iter.next();
357            } else if long.starts_with("expression=") || long.starts_with("file=") {
358                has_script = true;
359            }
360            continue;
361        }
362        if let Some(cluster) = arg.strip_prefix('-')
363            && !cluster.is_empty()
364        {
365            // BSD `sed -i ''`: the empty suffix arrives as its own word.
366            if bin == "sed" && cluster == "i" && iter.as_slice().first() == Some(&"") {
367                iter.next();
368            }
369            for (idx, ch) in cluster.char_indices() {
370                if ch == 'i' {
371                    in_place = true;
372                    break;
373                }
374                if script_flags.contains(&ch) {
375                    has_script = true;
376                    if idx + ch.len_utf8() == cluster.len() {
377                        iter.next();
378                    }
379                    break;
380                }
381                if ch == 'l' && bin == "sed" {
382                    if idx + 1 == cluster.len() {
383                        iter.next();
384                    }
385                    break;
386                }
387            }
388            continue;
389        }
390        operands.push(arg);
391    }
392    if !has_script && !operands.is_empty() {
393        operands.remove(0);
394    }
395    (
396        in_place,
397        operands.into_iter().filter(|a| is_plain(a)).collect(),
398    )
399}
400
401/// Destinations of `cp`/`mv`/`install`. With a directory destination the
402/// file lands at `DIR/basename(src)`; whether `DEST` is a directory is not
403/// known here, so both spellings are reported (a path that does not exist
404/// cancels nothing).
405fn copy_targets(args: &[&str]) -> Vec<String> {
406    const VALUE_FLAGS: &[&str] = &[
407        "-S", "--suffix", "-m", "--mode", "-o", "--owner", "-g", "--group",
408    ];
409    let mut target_dir: Option<&str> = None;
410    let mut iter = args.iter();
411    let mut rest = Vec::new();
412    while let Some(&arg) = iter.next() {
413        if arg == "-t" || arg == "--target-directory" {
414            target_dir = iter.next().copied();
415        } else if let Some(dir) = arg.strip_prefix("--target-directory=") {
416            target_dir = Some(dir);
417        } else {
418            rest.push(arg);
419        }
420    }
421    let operands: Vec<&str> = positional(&rest, VALUE_FLAGS)
422        .into_iter()
423        .filter(|a| is_edit_target(a))
424        .collect();
425    let basename = |src: &str| {
426        src.trim_end_matches('/')
427            .rsplit('/')
428            .next()
429            .map(str::to_string)
430    };
431    let mut out = Vec::new();
432    if let Some(dir) = target_dir.filter(|d| is_edit_target(d)) {
433        for src in &operands {
434            if let Some(name) = basename(src) {
435                out.push(format!("{}/{name}", dir.trim_end_matches('/')));
436            }
437        }
438        return out;
439    }
440    let Some((dest, sources)) = operands.split_last() else {
441        return out;
442    };
443    if sources.is_empty() {
444        return out;
445    }
446    out.push((*dest).to_string());
447    for src in sources {
448        if let Some(name) = basename(src) {
449            out.push(format!("{}/{name}", dest.trim_end_matches('/')));
450        }
451    }
452    out
453}
454
455/// One lexical word of a command segment.
456#[derive(Debug, Clone, PartialEq, Eq)]
457enum Word {
458    /// A word with quotes removed; `plain_path` is false when it contained an
459    /// unquoted expansion we cannot resolve.
460    Text { text: String, plain_path: bool },
461    /// A redirection operator; `output` for `>`-family, `dup` for `>&N`.
462    Redirect { output: bool, dup: bool },
463}
464
465/// Quote-aware lexer that also splits unquoted redirection operators off
466/// their neighbours (`echo hi>out`, `2>&1`, `&>log`). A quoted `">"` stays a
467/// plain word.
468fn lex(segment: &str) -> Vec<Word> {
469    let mut words = Vec::new();
470    let mut current = String::new();
471    let mut started = false;
472    let mut plain = true;
473    let mut in_single = false;
474    let mut in_double = false;
475    let chars: Vec<char> = segment.chars().collect();
476    let mut i = 0;
477
478    let flush =
479        |words: &mut Vec<Word>, current: &mut String, started: &mut bool, plain: &mut bool| {
480            if *started {
481                words.push(Word::Text {
482                    text: std::mem::take(current),
483                    plain_path: *plain,
484                });
485            }
486            *started = false;
487            *plain = true;
488        };
489
490    while i < chars.len() {
491        let c = chars[i];
492        if in_single {
493            if c == '\'' {
494                in_single = false;
495            } else {
496                current.push(c);
497            }
498            i += 1;
499            continue;
500        }
501        if in_double {
502            match c {
503                '"' => in_double = false,
504                '\\' if matches!(chars.get(i + 1), Some('$' | '`' | '"' | '\\')) => {
505                    current.push(chars[i + 1]);
506                    i += 1;
507                }
508                '$' | '`' => {
509                    plain = false;
510                    current.push(c);
511                }
512                _ => current.push(c),
513            }
514            i += 1;
515            continue;
516        }
517        match c {
518            '\'' => {
519                in_single = true;
520                started = true;
521            }
522            '"' => {
523                in_double = true;
524                started = true;
525            }
526            '\\' => {
527                if let Some(&next) = chars.get(i + 1) {
528                    current.push(next);
529                    started = true;
530                    i += 1;
531                }
532            }
533            c if c.is_whitespace() => flush(&mut words, &mut current, &mut started, &mut plain),
534            '>' | '<' if chars.get(i + 1) != Some(&'(') => {
535                // An all-digit (or `&`) word directly before the operator is
536                // its file descriptor, not an argument.
537                let fd_prefix = started
538                    && !current.is_empty()
539                    && (current.chars().all(|ch| ch.is_ascii_digit()) || current == "&");
540                if fd_prefix {
541                    current.clear();
542                    started = false;
543                    plain = true;
544                } else {
545                    flush(&mut words, &mut current, &mut started, &mut plain);
546                }
547                let output = c == '>';
548                let mut j = i + 1;
549                // `>>`, `>|`, `<<`, `<<<`, `<<-`, `<>`.
550                while j < chars.len() && matches!(chars[j], '>' | '<' | '|' | '-') {
551                    if chars[j] == '-' && !(c == '<' && chars[j - 1] == '<') {
552                        break;
553                    }
554                    j += 1;
555                }
556                let dup = chars.get(j) == Some(&'&');
557                if dup {
558                    j += 1;
559                }
560                words.push(Word::Redirect { output, dup });
561                i = j;
562                continue;
563            }
564            '$' | '`' | '*' | '?' => {
565                plain = false;
566                started = true;
567                current.push(c);
568            }
569            _ => {
570                started = true;
571                current.push(c);
572            }
573        }
574        i += 1;
575    }
576    flush(&mut words, &mut current, &mut started, &mut plain);
577    words
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    fn edits(cmd: &str) -> Vec<String> {
585        analyze(cmd).edits
586    }
587
588    fn reads(cmd: &str) -> Vec<(String, ShellReadKind)> {
589        analyze(cmd).reads
590    }
591
592    use ShellReadKind::{Full, Partial};
593
594    const NO_EDITS: [String; 0] = [];
595    const NO_READS: [(String, ShellReadKind); 0] = [];
596
597    #[test]
598    fn sed_in_place_is_an_edit() {
599        assert_eq!(edits("sed -i 's/a/b/' src/a.rs"), ["src/a.rs"]);
600        assert_eq!(edits("sed -i.bak -e 's/a/b/' a.rs b.rs"), ["a.rs", "b.rs"]);
601        assert_eq!(edits("sed -i '' 's/a/b/' 'my file.rs'"), ["my file.rs"]);
602        assert_eq!(edits("sed -Ei 's/a/b/g' a.rs"), ["a.rs"]);
603        assert_eq!(edits("sed --in-place -e 's/x/y/' a.rs"), ["a.rs"]);
604        assert_eq!(edits("sed 's/a/b/' a.rs"), NO_EDITS);
605    }
606
607    #[test]
608    fn sed_without_in_place_is_a_partial_read() {
609        assert_eq!(
610            reads("sed -n '10,20p' src/a.rs"),
611            [("src/a.rs".into(), Partial)]
612        );
613        assert_eq!(analyze("sed -n '10,20p' src/a.rs").edits, NO_EDITS);
614    }
615
616    #[test]
617    fn perl_in_place_is_an_edit() {
618        assert_eq!(edits("perl -0pi -e 's/a/b/' a.rs"), ["a.rs"]);
619        assert_eq!(edits("perl -pi -e 's/a/b/' a.rs b.rs"), ["a.rs", "b.rs"]);
620        assert_eq!(edits("perl -ne 'print' a.rs"), NO_EDITS);
621    }
622
623    #[test]
624    fn output_redirections_are_edits() {
625        assert_eq!(edits("echo hi > out.txt"), ["out.txt"]);
626        assert_eq!(edits("echo hi >> log.txt"), ["log.txt"]);
627        assert_eq!(edits("echo hi>out.txt"), ["out.txt"]);
628        assert_eq!(edits("printf x >| clobber.txt"), ["clobber.txt"]);
629        assert_eq!(edits("cmd &> both.log"), ["both.log"]);
630        assert_eq!(edits("cmd 1> one.txt"), ["one.txt"]);
631        assert_eq!(edits("echo hi > \"with space.txt\""), ["with space.txt"]);
632        assert_eq!(edits("cat > new.rs <<'EOF'\nfn main() {}\nEOF"), ["new.rs"]);
633    }
634
635    #[test]
636    fn stream_plumbing_is_never_an_edit() {
637        assert_eq!(edits("cargo build 2>/dev/null"), NO_EDITS);
638        assert_eq!(edits("cargo build 2>&1"), NO_EDITS);
639        assert_eq!(edits("echo oops >&2"), NO_EDITS);
640        assert_eq!(edits("cmd > /dev/null 2>&1"), NO_EDITS);
641        assert_eq!(edits("cmd &>/dev/null"), NO_EDITS);
642        assert_eq!(edits("echo hi > \"$OUT\""), NO_EDITS);
643        assert_eq!(edits("echo hi > $(mktemp)"), NO_EDITS);
644        assert_eq!(edits("grep '>' src/a.rs"), NO_EDITS);
645        assert_eq!(edits("diff <(sort a) >(cat)"), NO_EDITS);
646        assert_eq!(edits("wc -l < input.txt"), NO_EDITS);
647    }
648
649    #[test]
650    fn tee_targets_are_edits() {
651        assert_eq!(edits("echo x | tee out.txt"), ["out.txt"]);
652        assert_eq!(edits("echo x | tee -a a.txt b.txt"), ["a.txt", "b.txt"]);
653        assert_eq!(edits("echo x | tee /dev/stderr"), NO_EDITS);
654    }
655
656    #[test]
657    fn copy_and_move_destinations_are_edits() {
658        assert_eq!(edits("cp new.rs src/a.rs"), ["src/a.rs", "src/a.rs/new.rs"]);
659        assert_eq!(
660            edits("mv -f tmp.rs src/a.rs"),
661            ["src/a.rs", "src/a.rs/tmp.rs"]
662        );
663        assert_eq!(edits("cp -t src a.rs b.rs"), ["src/a.rs", "src/b.rs"]);
664        assert_eq!(edits("cp only_one"), NO_EDITS);
665    }
666
667    #[test]
668    fn chains_and_cd_are_followed() {
669        assert_eq!(
670            edits("cd rust && sed -i 's/a/b/' src/a.rs; echo done > /tmp/x.log"),
671            ["rust/src/a.rs", "/tmp/x.log"]
672        );
673        assert_eq!(edits("make || echo fail > err.txt"), ["err.txt"]);
674        assert_eq!(edits("lean-ctx -c \"sed -i 's/a/b/' a.rs\""), ["a.rs"]);
675        assert_eq!(edits("cd sub && bash -c 'echo x > o.txt'"), ["sub/o.txt"]);
676    }
677
678    #[test]
679    fn apply_patch_bodies_are_edits() {
680        let cmd = "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: src/a.rs\n@@\n-a\n+b\n*** Add File: src/new.rs\n+x\n*** Delete File: old.rs\n*** Update File: src/m.rs\n*** Move to: src/n.rs\n*** End Patch\nEOF";
681        assert_eq!(
682            edits(cmd),
683            ["src/a.rs", "src/new.rs", "old.rs", "src/m.rs", "src/n.rs"]
684        );
685        // Diff lines inside the body are not redirections.
686        assert_eq!(
687            edits("apply_patch '*** Begin Patch\n*** Update File: a.rs\n+x > y\n*** End Patch'"),
688            ["a.rs"]
689        );
690    }
691
692    #[test]
693    fn full_and_partial_reads() {
694        assert_eq!(reads("cat src/a.rs"), [("src/a.rs".into(), Full)]);
695        assert_eq!(
696            reads("cat -n a.rs b.rs"),
697            [("a.rs".into(), Full), ("b.rs".into(), Full)]
698        );
699        assert_eq!(reads("bat --paging never a.rs"), [("a.rs".into(), Full)]);
700        assert_eq!(reads("less +40 a.rs"), [("a.rs".into(), Full)]);
701        assert_eq!(reads("bat -r 10:20 a.rs"), [("a.rs".into(), Partial)]);
702        assert_eq!(reads("head -n 50 a.rs"), [("a.rs".into(), Partial)]);
703        assert_eq!(reads("tail -20 a.rs"), [("a.rs".into(), Partial)]);
704        assert_eq!(reads("cat a.rs | head -5"), [("a.rs".into(), Partial)]);
705        assert_eq!(reads("cat a.rs | grep x"), [("a.rs".into(), Partial)]);
706        assert_eq!(
707            reads("cd docs && cat guide.md"),
708            [("docs/guide.md".into(), Full)]
709        );
710        assert_eq!(reads("cat a.rs > b.rs"), NO_READS);
711        assert_eq!(reads("cat"), NO_READS);
712        assert_eq!(reads("ls -la"), NO_READS);
713    }
714}