pub fn split_segments(s: &str) -> Vec<Segment> {
let mut segments = Vec::new();
let mut cur = String::new();
let mut cur_redirects = Vec::new();
let mut cur_spans: Vec<(usize, usize)> = Vec::new();
let mut seg_start = 0;
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
let (mut in_single, mut in_double) = (false, false);
while i < chars.len() {
let c = chars[i];
match c {
'\'' if !in_double => {
in_single = !in_single;
cur.push(c);
}
'"' if !in_single => {
in_double = !in_double;
cur.push(c);
}
'\\' if !in_single && i + 1 < chars.len() => {
cur.push(c);
cur.push(chars[i + 1]);
i += 1;
}
_ if in_single || in_double => cur.push(c),
'&' if i + 1 < chars.len() && chars[i + 1] == '&' => {
flush(
&mut segments,
&mut cur,
&mut cur_redirects,
&chars[seg_start..i],
seg_start,
&mut cur_spans,
);
seg_start = i + 2;
i += 1; }
'&' if !is_redirection_amp(&chars, i) => {
flush(
&mut segments,
&mut cur,
&mut cur_redirects,
&chars[seg_start..i],
seg_start,
&mut cur_spans,
);
seg_start = i + 1;
}
'>' => {
let prev_is_amp = i > 0 && chars[i - 1] == '&';
let prev_is_lt = i > 0 && chars[i - 1] == '<';
let start = if prev_is_amp || prev_is_lt {
i - 1
} else {
span_start(&chars, seg_start, i)
};
cur.push(c);
let mut j = i + 1;
let truncates = if chars.get(j) == Some(&'>') {
cur.push('>');
j += 1;
false
} else {
true
};
if truncates && chars.get(j) == Some(&'|') {
cur.push('|');
j += 1;
}
if chars.get(j) == Some(&'&') {
cur.push('&');
let end = descriptor(&chars, &mut cur, j + 1);
cur_spans.push((start, end));
i = end;
continue;
}
while j < chars.len() && chars[j].is_whitespace() {
cur.push(chars[j]);
j += 1;
}
if chars.get(j) == Some(&'(') {
i = j;
continue;
}
let (target, end) = target_word(&chars, &mut cur, j);
if !target.is_empty() && !prev_is_amp && !prev_is_lt && !is_sink(&target) {
cur_redirects.push(Overwrite { target, truncates });
}
cur_spans.push((start, end));
i = end;
continue;
}
'|' => {
flush(
&mut segments,
&mut cur,
&mut cur_redirects,
&chars[seg_start..i],
seg_start,
&mut cur_spans,
);
if i + 1 < chars.len() && chars[i + 1] == '|' {
i += 1; }
seg_start = i + 1;
}
';' | '\n' => {
flush(
&mut segments,
&mut cur,
&mut cur_redirects,
&chars[seg_start..i],
seg_start,
&mut cur_spans,
);
seg_start = i + 1;
}
'<' => {
let start = span_start(&chars, seg_start, i);
cur.push(c);
let mut j = i + 1;
if chars.get(j) == Some(&'<') {
cur.push('<');
j += 1;
if chars.get(j) == Some(&'<') {
cur.push('<');
j += 1;
}
} else if chars.get(j) == Some(&'>') {
cur.push('>');
j += 1;
}
if chars.get(j) == Some(&'&') {
cur.push('&');
j = descriptor(&chars, &mut cur, j + 1);
cur_spans.push((start, j));
i = j;
continue;
}
while j < chars.len() && chars[j].is_whitespace() {
cur.push(chars[j]);
j += 1;
}
if chars.get(j) == Some(&'(') {
i = j;
continue;
}
let (_, end) = target_word(&chars, &mut cur, j);
cur_spans.push((start, end));
i = end;
continue;
}
_ => cur.push(c),
}
i += 1;
}
flush(
&mut segments,
&mut cur,
&mut cur_redirects,
&chars[seg_start..],
seg_start,
&mut cur_spans,
);
segments
}
fn span_start(chars: &[char], seg_start: usize, op: usize) -> usize {
let mut k = op;
while k > seg_start && chars[k - 1].is_ascii_digit() {
k -= 1;
}
if k < op && (k == seg_start || chars[k - 1].is_whitespace()) {
k
} else {
op
}
}
fn descriptor(chars: &[char], cur: &mut String, mut j: usize) -> usize {
while j < chars.len() && (chars[j].is_ascii_digit() || chars[j] == '-') {
cur.push(chars[j]);
j += 1;
}
j
}
fn target_word(chars: &[char], cur: &mut String, mut j: usize) -> (String, usize) {
let mut target = String::new();
let mut quote: Option<char> = None;
while j < chars.len() {
let d = chars[j];
match quote {
Some(q) if d == q => quote = None,
Some(_) => {}
None if d == '\'' || d == '"' => quote = Some(d),
None if d == '\\' && j + 1 < chars.len() => {
cur.push(d);
target.push(d);
j += 1;
}
None if d.is_whitespace() => break,
None if matches!(d, ';' | '\n' | '|') => break,
None if d == '&' && !is_redirection_amp(chars, j) => break,
None => {}
}
cur.push(chars[j]);
target.push(chars[j]);
j += 1;
}
let target: String = target.trim_matches(|c| c == '\'' || c == '"').to_string();
(target, j)
}
const POSIX_SHELLS: [&str; 4] = ["sh", "bash", "dash", "zsh"];
const MAX_WRAP_DEPTH: usize = 4;
pub fn split_segments_deep(s: &str) -> Vec<Segment> {
let mut out = Vec::new();
expand_into(&mut out, split_segments(s), None, 0);
out
}
fn expand_into(out: &mut Vec<Segment>, segments: Vec<Segment>, via: Option<&str>, depth: usize) {
let mut bindings: Vec<(String, String)> = Vec::new();
let mut inner: Vec<Segment> = Vec::new();
for mut seg in segments {
if let Some(v) = via {
seg.via = Some(v.to_string());
}
if !bindings.is_empty() {
substitute_bindings(&mut seg, &bindings);
}
if let Some((name, value)) = simple_assignment(&seg) {
bindings.retain(|(n, _)| n != &name);
bindings.push((name, value));
seg.binds = true;
}
inner.push(seg);
}
let snapshot = via.is_some() && claude_snapshot_inner(&inner).is_some();
for mut seg in inner {
seg.scaffold = via.is_some() && (snapshot || harness_scaffold(&seg));
let inner = if depth < MAX_WRAP_DEPTH {
wrapped_command(&seg).or_else(|| wrapped_eval(&seg))
} else {
None
};
seg.wraps = inner.is_some();
out.push(seg);
if let Some((shell, script)) = inner {
let label = if shell == "eval" {
"eval".to_string()
} else {
format!("{shell} -c")
};
expand_into(out, split_segments(&script), Some(&label), depth + 1);
}
}
}
fn simple_assignment(seg: &Segment) -> Option<(String, String)> {
if !seg.redirects.is_empty() {
return None;
}
let toks = crate::delete::tokenize_detailed(seg.command());
let (word, exported) = match toks.as_slice() {
[w] => (w, false),
[e, w] if e.text == "export" => (w, true),
_ => return None,
};
let _ = exported;
let (name, value) = word.text.split_once('=')?;
let ident = !name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !ident {
return None;
}
let raw_value = seg
.command()
.split_once('=')
.map(|(_, v)| v.trim())
.unwrap_or("");
const FORBIDDEN: &[char] = &[
'$', '`', '*', '?', '[', ';', '&', '|', '<', '>', '(', ')', ' ', '\t', '\n', '\\',
];
if raw_value.contains(FORBIDDEN) {
return None;
}
if !word.single_quoted && value.contains(FORBIDDEN) {
return None;
}
Some((name.to_string(), value.to_string()))
}
fn substitute_bindings(seg: &mut Segment, bindings: &[(String, String)]) {
fn subst(text: &str, name: &str, value: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut in_single = false;
let mut i = 0;
let ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
while i < chars.len() {
let c = chars[i];
if c == '\\' && !in_single && i + 1 < chars.len() {
out.push(c);
out.push(chars[i + 1]);
i += 2;
continue;
}
if c == '\'' {
in_single = !in_single;
out.push(c);
i += 1;
continue;
}
if c == '$' && !in_single {
let rest: String = chars[i + 1..].iter().collect();
if let Some(after) = rest.strip_prefix('{') {
if let Some(tail) = after.strip_prefix(name) {
if tail.starts_with('}') {
out.push_str(value);
i += 1 + 1 + name.chars().count() + 1;
continue;
}
}
} else if let Some(tail) = rest.strip_prefix(name) {
if !tail.chars().next().is_some_and(ident) {
out.push_str(value);
i += 1 + name.chars().count();
continue;
}
}
}
out.push(c);
i += 1;
}
out
}
if !seg.text.contains('$') {
return;
}
for (name, value) in bindings {
seg.text = subst(&seg.text, name, value);
for r in &mut seg.redirects {
r.target = subst(&r.target, name, value);
}
}
}
fn claude_snapshot_inner(inner: &[Segment]) -> Option<(String, usize)> {
let first = inner.first()?;
if !first.binds {
return None;
}
let (name, path) = simple_assignment(first)?;
if name != "SNAPSHOT_FILE" || !is_claude_snapshot(&path) {
return None;
}
for seg in inner {
for r in &seg.redirects {
if r.target.trim_matches('"') != path {
return None;
}
}
}
Some((path, inner.len()))
}
pub fn claude_snapshot(segs: &[Segment]) -> Option<(String, usize)> {
let wrapper = segs.first()?;
if !wrapper.wraps {
return None;
}
let label = format!(
"{} -c",
crate::delete::tokenize_public(wrapper.command()).first()?
);
let inner: Vec<Segment> = segs
.iter()
.skip(1)
.filter(|s| s.via.as_deref() == Some(label.as_str()))
.cloned()
.collect();
claude_snapshot_inner(&inner)
}
fn harness_scaffold(seg: &Segment) -> bool {
let words: Vec<String> = crate::delete::tokenize_public(seg.command())
.into_iter()
.map(|w| w.trim_start_matches('\\').to_string())
.collect();
let w: Vec<&str> = words.iter().map(String::as_str).collect();
match w.as_slice() {
["shopt", "-u", "extglob"]
| ["setopt", "NO_EXTENDED_GLOB", "NO_BARE_GLOB_QUAL"]
| ["true"]
| ["{", "builtin", "unalias", "--", "unsetenv"]
| ["builtin", "unset", "-f", "--", "unsetenv"]
| ["}"] => true,
["source", path] => is_claude_snapshot(path),
["pwd", "-P"] => {
seg.redirects.len() == 1
&& seg.redirects[0].truncates
&& is_claude_cwd_file(&seg.redirects[0].target)
}
_ => false,
}
}
fn is_claude_snapshot(path: &str) -> bool {
let Some((dir, file)) = path.rsplit_once('/') else {
return false;
};
if !dir.ends_with("/.claude/shell-snapshots") || dir.split('/').any(|c| c == "..") {
return false;
}
let Some(mid) = file
.strip_prefix("snapshot-")
.and_then(|r| r.strip_suffix(".sh"))
else {
return false;
};
let parts: Vec<&str> = mid.splitn(3, '-').collect();
parts.len() == 3
&& parts[0].chars().all(|c| c.is_ascii_alphanumeric())
&& parts[1].chars().all(|c| c.is_ascii_digit())
&& parts[2].chars().all(|c| c.is_ascii_alphanumeric())
&& parts.iter().all(|p| !p.is_empty())
}
fn is_claude_cwd_file(target: &str) -> bool {
target
.strip_prefix("/tmp/claude-")
.and_then(|r| r.strip_suffix("-cwd"))
.is_some_and(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()))
}
fn wrapped_eval(seg: &Segment) -> Option<(String, String)> {
let toks = crate::delete::tokenize_detailed(seg.command());
let words: Vec<String> = toks.iter().map(|t| t.text.clone()).collect();
let (head, at) = crate::delete::resolve_head(&words)?;
if head != "eval" || toks.len() != at + 2 {
return None;
}
let arg = &toks[at + 1];
if !arg.single_quoted || arg.text.trim().is_empty() {
return None;
}
Some((head, arg.text.clone()))
}
pub fn context_text(command: &str) -> String {
let segs = split_segments_deep(command);
if segs.len() <= 1 {
return command.to_string();
}
let kept: Vec<&str> = segs
.iter()
.filter(|s| !s.wraps && !s.scaffold && !s.binds)
.map(|s| s.text.as_str())
.collect();
kept.join("; ")
}
fn wrapped_command(seg: &Segment) -> Option<(String, String)> {
let tokens = crate::delete::tokenize_public(seg.command());
let (head, at) = crate::delete::resolve_head(&tokens)?;
if !POSIX_SHELLS.contains(&head.as_str()) {
return None;
}
let mut i = at + 1;
while let Some(tok) = tokens.get(i) {
if tok == "--" || tok == "-" || !tok.starts_with('-') {
return None; }
if tok == "-o" {
i += 2;
continue;
}
if !tok.starts_with("--") && tok[1..].contains('c') {
let mut j = i + 1;
while let Some(t) = tokens.get(j) {
if t == "--" {
j += 1;
break;
}
if t == "-" || !t.starts_with('-') {
break;
}
j += if t == "-o" { 2 } else { 1 };
}
let script = tokens.get(j)?;
if script.trim().is_empty() {
return None;
}
return Some((head, script.clone()));
}
i += 1;
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Segment {
text: String,
command: String,
pub redirects: Vec<Overwrite>,
pub via: Option<String>,
pub wraps: bool,
pub scaffold: bool,
pub binds: bool,
}
impl Segment {
pub fn command(&self) -> &str {
&self.command
}
}
impl std::ops::Deref for Segment {
type Target = str;
fn deref(&self) -> &str {
&self.text
}
}
impl std::fmt::Display for Segment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.text)
}
}
impl PartialEq<&str> for Segment {
fn eq(&self, other: &&str) -> bool {
self.text == *other
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Overwrite {
pub target: String,
pub truncates: bool,
}
fn is_sink(target: &str) -> bool {
matches!(
target.to_ascii_lowercase().as_str(),
"/dev/null"
| "/dev/zero"
| "/dev/stdout"
| "/dev/stderr"
| "/dev/tty"
| "/dev/full"
| "nul"
)
}
fn is_redirection_amp(chars: &[char], i: usize) -> bool {
let prev_is_redirect = i > 0 && matches!(chars[i - 1], '>' | '<');
let next_is_redirect = chars.get(i + 1) == Some(&'>');
prev_is_redirect || next_is_redirect
}
fn flush(
segments: &mut Vec<Segment>,
cur: &mut String,
redirects: &mut Vec<Overwrite>,
source: &[char],
seg_start: usize,
spans: &mut Vec<(usize, usize)>,
) {
let t = cur.trim();
if !t.is_empty() {
let command: String = source
.iter()
.enumerate()
.filter(|(k, _)| {
let at = seg_start + k;
!spans.iter().any(|&(a, b)| a <= at && at < b)
})
.map(|(_, c)| *c)
.collect();
segments.push(Segment {
text: t.to_string(),
command: command.trim().to_string(),
redirects: std::mem::take(redirects),
via: None,
wraps: false,
scaffold: false,
binds: false,
});
} else {
redirects.clear();
}
spans.clear();
cur.clear();
}
pub fn substitutions(s: &str) -> Vec<String> {
let chars: Vec<char> = s.chars().collect();
let mut out = Vec::new();
let mut in_single = false;
let mut i = 0;
while i < chars.len() {
match chars[i] {
'\'' => {
in_single = !in_single;
i += 1;
}
'`' if !in_single => {
let start = i + 1;
let mut j = start;
while j < chars.len() && chars[j] != '`' {
j += 1;
}
out.push(chars[start..j].iter().collect());
i = j + 1;
}
'$' if !in_single && chars.get(i + 1) == Some(&'(') => {
let start = i + 2;
let mut depth = 1;
let mut j = start;
let mut inner_single = false;
while j < chars.len() {
match chars[j] {
'\'' => inner_single = !inner_single,
'(' if !inner_single => depth += 1,
')' if !inner_single => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
j += 1;
}
out.push(chars[start..j.min(chars.len())].iter().collect());
i = j + 1;
}
_ => i += 1,
}
}
out
}
pub fn is_literal_heredoc(inner: &str) -> bool {
let t = inner.trim_start();
let Some(rest) = t.strip_prefix("cat") else {
return false;
};
let rest = rest.trim_start();
let Some(rest) = rest.strip_prefix("<<") else {
return false;
};
let rest = rest.trim_start_matches('-').trim_start();
rest.starts_with('\'') || rest.starts_with('"')
}
pub fn has_substitution(s: &str) -> bool {
let chars: Vec<char> = s.chars().collect();
let mut in_single = false;
let mut i = 0;
while i < chars.len() {
match chars[i] {
'\'' => in_single = !in_single,
'`' if !in_single => return true,
'$' if !in_single && chars.get(i + 1) == Some(&'(') => return true,
_ => {}
}
i += 1;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn redirects(cmd: &str) -> Vec<Overwrite> {
split_segments(cmd)
.into_iter()
.flat_map(|s| s.redirects)
.collect()
}
#[test]
fn truncating_redirects_are_extracted() {
let r = redirects("cat /dev/null > .env");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, ".env");
assert!(r[0].truncates);
assert_eq!(redirects("ls -la > /etc/hosts")[0].target, "/etc/hosts");
assert_eq!(redirects("echo x >config.json")[0].target, "config.json");
assert_eq!(
redirects(r#"echo x > "my file.txt""#)[0].target,
"my file.txt"
);
}
#[test]
fn appending_is_recorded_but_not_truncating() {
let r = redirects("echo entry >> app.log");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, "app.log");
assert!(!r[0].truncates, ">> appends, it does not destroy");
}
#[test]
fn descriptor_redirects_are_not_file_targets() {
for cmd in [
"make 2>&1",
"cmd >&2",
"cmd <&-",
"cmd &> log",
"cmd &>> log",
"make 2>&1 | tee out",
] {
assert!(
redirects(cmd).is_empty(),
"{cmd} redirects a descriptor, it does not truncate a file"
);
}
}
#[test]
fn quoted_redirects_are_text() {
assert!(redirects(r#"echo "a > b""#).is_empty());
assert!(redirects("echo 'x > y'").is_empty());
assert!(redirects(r#"git commit -m "fix > bug""#).is_empty());
}
#[test]
fn the_command_text_leaves_every_redirection_out() {
for (cmd, want) in [
("rm -rf ./cache 2>/dev/null", vec!["rm -rf ./cache"]),
("rm -rf ./cache > /dev/null 2>&1", vec!["rm -rf ./cache"]),
("rm -rf ./cache >log 2>&1", vec!["rm -rf ./cache"]),
("rm -rf ./cache 2>&1 >/dev/null", vec!["rm -rf ./cache"]),
("cat /dev/null > src/main.rs", vec!["cat /dev/null"]),
("sort < in.txt > out.txt", vec!["sort"]),
("cmd &>all.log", vec!["cmd"]),
("cmd &>>all.log", vec!["cmd"]),
("echo a >> log", vec!["echo a"]),
("echo a >| log", vec!["echo a"]),
("cat > \"my file.txt\"", vec!["cat"]),
("rm > early -rf ./cache", vec!["rm -rf ./cache"]),
("cat <<EOF\nbody\nEOF", vec!["cat", "body", "EOF"]),
("cat <<< word", vec!["cat"]),
("exec 3<&0", vec!["exec"]),
("exec 3<> /tmp/f", vec!["exec"]),
("rm -rf ./cache2>log", vec!["rm -rf ./cache2"]),
("tee >(gzip) file", vec!["tee >(gzip) file"]),
("diff <(ls a) <(ls b)", vec!["diff <(ls a) <(ls b)"]),
(
"git status & rm -rf / 2>/dev/null",
vec!["git status", "rm -rf /"],
),
("echo a > x && echo b > y", vec!["echo a", "echo b"]),
("echo \"a > b\"", vec!["echo \"a > b\""]),
] {
let segs = split_segments(cmd);
let got: Vec<&str> = segs.iter().map(|s| s.command()).collect();
assert_eq!(got, want, "{cmd}");
}
}
#[test]
fn the_command_text_changes_nothing_about_the_text_or_the_redirects() {
for (cmd, texts) in [
(
"rm -rf ./cache > /dev/null 2>&1",
vec!["rm -rf ./cache > /dev/null 2>&1"],
),
("sort < in.txt > out.txt", vec!["sort < in.txt > out.txt"]),
("cat <<EOF\nbody\nEOF", vec!["cat <<EOF", "body", "EOF"]),
("exec 3<&0", vec!["exec 3<&0"]),
("tee >(gzip) file", vec!["tee >(gzip) file"]),
("cat > \"my file.txt\"", vec!["cat > \"my file.txt\""]),
("echo \"a > b\"", vec!["echo \"a > b\""]),
("git status & rm -rf /", vec!["git status", "rm -rf /"]),
] {
let segs = split_segments(cmd);
let got: Vec<&str> = segs.iter().map(|s| &**s).collect();
assert_eq!(got, texts, "{cmd}: the text is verbatim");
}
let targets =
|cmd: &str| -> Vec<String> { redirects(cmd).into_iter().map(|o| o.target).collect() };
assert_eq!(targets("rm -rf ./cache >log 2>&1"), vec!["log"]);
assert_eq!(targets("cat > \"my file.txt\""), vec!["my file.txt"]);
assert!(redirects("sort < in.txt").is_empty());
let seg = &split_segments("rm -rf ./cache")[0];
assert_eq!(seg.command(), &**seg);
}
#[test]
fn options_after_dash_c_are_stepped_over() {
for cmd in [
r#"zsh -c -l "rm -rf ./dist""#,
r#"bash -c -e -o pipefail "rm -rf ./dist""#,
r#"sh -c -- "rm -rf ./dist""#,
] {
let segs = split_segments_deep(cmd);
assert!(
segs.iter().any(|s| s.command().contains("rm -rf ./dist")
&& !s.command().starts_with("zsh")
&& !s.command().starts_with("bash")
&& !s.command().starts_with("sh")),
"the inner command must be its own segment for {cmd}: {:?}",
segs.iter()
.map(|s| s.command().to_string())
.collect::<Vec<_>>()
);
}
assert_eq!(split_segments_deep("sh -c -l").len(), 1);
}
#[test]
fn a_posix_shell_c_string_is_read_as_its_own_segments() {
let segs = split_segments_deep(r#"sh -c "cat /dev/null > src/main.rs""#);
let texts: Vec<&str> = segs.iter().map(|s| &**s).collect();
assert_eq!(
texts,
[
r#"sh -c "cat /dev/null > src/main.rs""#,
"cat /dev/null > src/main.rs"
]
);
assert_eq!(segs[0].via, None, "the wrapper was typed at the top level");
assert_eq!(segs[1].via.as_deref(), Some("sh -c"));
assert!(
segs[0].redirects.is_empty(),
"the quoted `>` is text to the wrapper"
);
assert_eq!(segs[1].redirects[0].target, "src/main.rs");
let segs = split_segments_deep(r#"bash -lc "rm -rf ./dist && echo done""#);
let texts: Vec<&str> = segs.iter().map(|s| &**s).collect();
assert_eq!(
texts,
[
r#"bash -lc "rm -rf ./dist && echo done""#,
"rm -rf ./dist",
"echo done"
]
);
assert!(segs[1..]
.iter()
.all(|s| s.via.as_deref() == Some("bash -c")));
for cmd in [
r#"sudo sh -c "rm -rf ./dist""#,
r#"/bin/sh -c "rm -rf ./dist""#,
r#"dash -c "rm -rf ./dist""#,
r#"zsh -ec "rm -rf ./dist""#,
r#"sh -e -c "rm -rf ./dist""#,
r#"bash --norc -c "rm -rf ./dist""#,
r#"bash -o pipefail -c "rm -rf ./dist""#,
"sh -c 'rm -rf ./dist'",
] {
let segs = split_segments_deep(cmd);
assert_eq!(segs.len(), 2, "{cmd}");
assert_eq!(&*segs[1], "rm -rf ./dist", "{cmd}");
}
let segs = split_segments_deep(r#"sh -c "echo hi" > out.log"#);
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].redirects[0].target, "out.log");
assert!(segs[1].redirects.is_empty());
assert_eq!(&*segs[1], "echo hi");
}
#[test]
fn claude_codes_preamble_is_scaffolding_and_its_eval_is_read() {
let bash = r#"bash -c -l "shopt -u extglob 2>/dev/null || true && { \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'ls -la /home/dev/proj/ 2>&1; echo \"--- scratch check ---\"; ls -la /home/dev/proj/scratch 2>&1' < /dev/null && pwd -P >| /tmp/claude-c32c-cwd""#;
let segs = split_segments_deep(bash);
let texts: Vec<&str> = segs.iter().map(|s| &**s).collect();
assert_eq!(
texts,
[
bash,
"shopt -u extglob 2>/dev/null",
"true",
r"{ \builtin unalias -- 'unsetenv'",
r"\builtin unset -f -- 'unsetenv'",
"} >/dev/null 2>&1",
"true",
r#"eval 'ls -la /home/dev/proj/ 2>&1; echo "--- scratch check ---"; ls -la /home/dev/proj/scratch 2>&1' < /dev/null"#,
"ls -la /home/dev/proj/ 2>&1",
r#"echo "--- scratch check ---""#,
"ls -la /home/dev/proj/scratch 2>&1",
"pwd -P >| /tmp/claude-c32c-cwd",
]
);
let scaffold: Vec<bool> = segs.iter().map(|s| s.scaffold).collect();
assert_eq!(
scaffold,
[false, true, true, true, true, true, true, false, false, false, false, true]
);
assert!(
segs[0].wraps && segs[7].wraps,
"the shell and the eval are wrappers"
);
assert_eq!(segs[8].via.as_deref(), Some("eval"));
assert_eq!(segs[10].via.as_deref(), Some("eval"));
assert_eq!(
context_text(bash),
r#"ls -la /home/dev/proj/ 2>&1; echo "--- scratch check ---"; ls -la /home/dev/proj/scratch 2>&1"#,
"the context check reads the agent's command, not the preamble"
);
assert!(!context_text(bash).contains("unset -f"));
let zsh = r#"zsh -c -l "setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && { \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'rm -rf ./scratch' < /dev/null && pwd -P >| /tmp/claude-b1de-cwd""#;
let segs = split_segments_deep(zsh);
let inner: Vec<&str> = segs
.iter()
.filter(|s| !s.wraps && !s.scaffold)
.map(|s| &**s)
.collect();
assert_eq!(inner, ["rm -rf ./scratch"]);
assert_eq!(context_text(zsh), "rm -rf ./scratch");
let sourced = r#"bash -c "source /home/dev/.claude/shell-snapshots/snapshot-bash-1789077199590-d2kylp.sh 2>/dev/null || true && shopt -u extglob 2>/dev/null || true && eval 'git status' < /dev/null && pwd -P >| /tmp/claude-0a1b-cwd""#;
let segs = split_segments_deep(sourced);
assert!(segs[1].scaffold, "sourcing its own snapshot: {}", &*segs[1]);
let inner: Vec<&str> = segs
.iter()
.filter(|s| !s.wraps && !s.scaffold)
.map(|s| &**s)
.collect();
assert_eq!(inner, ["git status"]);
}
#[test]
fn substitutions_are_extracted_balanced_and_a_quoted_heredoc_is_literal() {
assert_eq!(
substitutions("git log origin/$(git branch --show-current) -1"),
["git branch --show-current"]
);
assert_eq!(
substitutions("echo $(dirname $(pwd)) `whoami`"),
["dirname $(pwd)", "whoami"]
);
assert_eq!(substitutions("echo '$(not one)'"), Vec::<String>::new());
assert_eq!(substitutions("echo $(unbalanced"), ["unbalanced"]);
assert_eq!(
substitutions("git commit -m \"$(cat <<'EOF'\nfeat: x (y)\nEOF\n)\""),
["cat <<'EOF'\nfeat: x (y)\nEOF\n"]
);
assert!(is_literal_heredoc("cat <<'EOF'\nbody\nEOF\n"));
assert!(is_literal_heredoc("cat <<\"EOF\"\nbody\nEOF\n"));
assert!(is_literal_heredoc("cat <<-'EOF'\n\tbody\n\tEOF\n"));
assert!(
!is_literal_heredoc("cat <<EOF\n$HOME\nEOF\n"),
"unquoted: expands"
);
assert!(!is_literal_heredoc("cat file"));
assert!(!is_literal_heredoc("rm -rf /"));
}
#[test]
fn a_variable_bound_earlier_in_the_string_resolves_and_the_snapshot_is_recognised() {
let script = concat!(
"SNAPSHOT_FILE=/home/dev/.claude/shell-snapshots/snapshot-bash-1789080866426-67di03.sh\n",
" source \"/home/dev/.bashrc\" < /dev/null\n",
" # First, create/clear the snapshot file\n",
" echo \"# Snapshot file\" >| \"$SNAPSHOT_FILE\"\n",
" echo \"unalias -a 2>/dev/null || true\" >> \"$SNAPSHOT_FILE\"\n",
" cat >> \"$SNAPSHOT_FILE\" << 'RIPGREP_FUNC_END'\n",
" function rg {\n",
" local _cc_bin=\"${CLAUDE_CODE_EXECPATH:-}\"\n",
" [[ -x $_cc_bin ]] || _cc_bin=/home/dev/.local/bin/claude\n",
" if [[ ! -x $_cc_bin ]]; then command rg ${1+\"$@\"}; return; fi\n",
"}\n",
"RIPGREP_FUNC_END\n",
" declare -f | head -n 1000 >> \"$SNAPSHOT_FILE\"\n",
" cat >> \"$SNAPSHOT_FILE\" << 'PATH_END_f5fnw4s2sag'\n",
"export PATH=/home/dev/.termaxa/shims:/home/dev/.local/bin:/usr/bin:/bin\n",
"PATH_END_f5fnw4s2sag\n",
" if [ ! -f \"$SNAPSHOT_FILE\" ]; then\n",
" echo \"Error: Snapshot file was not created at $SNAPSHOT_FILE\" >&2\n",
" exit 1\n",
" fi\n",
);
let path = "/home/dev/.claude/shell-snapshots/snapshot-bash-1789080866426-67di03.sh";
let argv: Vec<String> = ["bash", "-c", "-l", script]
.iter()
.map(|s| s.to_string())
.collect();
let command = crate::runner::shell_join(&argv);
let segs = split_segments_deep(&command);
assert!(segs[0].wraps);
assert!(
segs[1].binds,
"the first inner segment binds: {}",
&*segs[1]
);
assert_eq!(&*segs[1], &format!("SNAPSHOT_FILE={path}"));
let writes: Vec<&str> = segs
.iter()
.flat_map(|s| s.redirects.iter())
.map(|r| r.target.as_str())
.collect();
assert!(!writes.is_empty());
assert!(
writes.iter().all(|t| t.trim_matches('"') == path),
"every write resolved to the bound path: {writes:?}"
);
assert!(
segs.iter().skip(1).all(|s| s.scaffold),
"recognised whole: every inner segment is scaffolding"
);
let (found, n) = claude_snapshot(&segs).expect("the snapshot is recognised");
assert_eq!(found, path);
assert_eq!(n, segs.len() - 1);
assert!(
!context_text(&command).contains("-f"),
"{}",
context_text(&command)
);
let segs = split_segments_deep("X=/a; echo one > $X; X=/b; echo two > $X");
let targets: Vec<&str> = segs
.iter()
.flat_map(|s| s.redirects.iter())
.map(|r| r.target.as_str())
.collect();
assert_eq!(targets, ["/a", "/b"]);
let drifted = script.replace(
"declare -f | head -n 1000 >> \"$SNAPSHOT_FILE\"",
"declare -f | head -n 1000 >> /home/dev/.bashrc",
);
let argv: Vec<String> = ["bash", "-c", "-l", drifted.as_str()]
.iter()
.map(|s| s.to_string())
.collect();
let segs = split_segments_deep(&crate::runner::shell_join(&argv));
assert!(claude_snapshot(&segs).is_none());
assert!(segs.iter().skip(1).any(|s| !s.scaffold));
for first in [
"SNAPSHOT_FILE=/home/dev/notes.sh",
"OTHER=/home/dev/.claude/shell-snapshots/snapshot-bash-1-a.sh",
"SNAPSHOT_FILE=/home/dev/.claude/shell-snapshots/../../x/snapshot-bash-1-a.sh",
] {
let cmd = format!(r#"bash -c "{first}; echo hi >| \"$SNAPSHOT_FILE\"""#);
assert!(
claude_snapshot(&split_segments_deep(&cmd)).is_none(),
"{first}"
);
}
}
#[test]
fn only_a_literal_assignment_binds_and_only_outside_single_quotes() {
let bound = |cmd: &str| -> Vec<String> {
split_segments_deep(cmd)
.iter()
.filter(|s| !s.binds)
.map(|s| s.text.clone())
.collect()
};
assert_eq!(bound("X=/tmp/a; rm -rf $X"), ["rm -rf /tmp/a"]);
assert_eq!(bound("export X=/tmp/a; rm -rf ${X}/b"), ["rm -rf /tmp/a/b"]);
assert_eq!(bound("X='/tmp/a'; rm -rf $X"), ["rm -rf /tmp/a"]);
assert_eq!(bound("X=\"/tmp/a\"; rm -rf $X"), ["rm -rf /tmp/a"]);
assert_eq!(
bound("X=; rm -rf $X/*"),
["rm -rf /*"],
"an empty value binds"
);
assert_eq!(bound("X=/tmp/a; rm -rf $XY"), ["rm -rf $XY"]);
assert_eq!(bound("X=/tmp/a; rm -rf '$X'"), ["rm -rf '$X'"]);
for not_bound in [
"X=$Y; rm -rf $X",
"X=$(pwd); rm -rf $X",
"X=`pwd`; rm -rf $X",
"X=*.log; rm -rf $X",
"X=\"a b\"; rm -rf $X",
"X=/tmp/a rm -rf $X",
"if true; then X=/tmp/a; fi; rm -rf $X",
] {
let segs = split_segments_deep(not_bound);
let last = segs.last().unwrap();
assert!(
last.text.contains("$X"),
"{not_bound}: nothing bound, read as written: {}",
last.text
);
}
let segs = split_segments_deep(r#"X=/tmp/a; sh -c "rm -rf $X""#);
let inner = segs.iter().find(|s| s.via.is_some()).unwrap();
assert!(inner.text.contains("$X"), "{}", inner.text);
assert!(!split_segments_deep("rm -rf x")[0].binds);
assert!(split_segments_deep("X=1")[0].binds);
}
#[test]
fn what_the_scaffolding_reading_leaves_alone() {
for top in ["shopt -u extglob", "true", "pwd -P >| /tmp/claude-c32c-cwd"] {
let segs = split_segments_deep(top);
assert!(!segs[0].scaffold, "typed at the top level: {top}");
}
for inside in [
"shopt -u extglob nullglob 2>/dev/null",
"setopt NO_EXTENDED_GLOB 2>/dev/null",
r"\\builtin unset -f -- 'rm'",
"source /home/dev/.bashrc 2>/dev/null",
"source /home/dev/.claude/shell-snapshots/../../.bashrc 2>/dev/null",
"pwd -P >| /home/dev/notes.txt",
"pwd -P >> /tmp/claude-c32c-cwd",
"pwd >| /tmp/claude-c32c-cwd",
] {
let segs = split_segments_deep(&format!(r#"bash -c "{inside}""#));
assert_eq!(segs.len(), 2, "{inside}");
assert!(!segs[1].scaffold, "not the measured form: {inside}");
}
let ok = split_segments_deep(
r#"bash -c "source /Users/x/.claude/shell-snapshots/snapshot-zsh-1-abc.sh 2>/dev/null""#,
);
assert!(ok[1].scaffold);
for not_read in [
r#"eval "$cmd""#,
"eval $cmd",
"eval 'ls' 'more'",
"eval",
"eval ''",
] {
let segs = split_segments_deep(not_read);
assert_eq!(segs.len(), 1, "{not_read}");
assert!(!segs[0].wraps, "{not_read}");
}
let segs = split_segments_deep("eval 'sh -c \"rm -rf ./dist\"'");
let texts: Vec<&str> = segs.iter().map(|s| &**s).collect();
assert_eq!(
texts,
[
"eval 'sh -c \"rm -rf ./dist\"'",
"sh -c \"rm -rf ./dist\"",
"rm -rf ./dist"
]
);
assert_eq!(segs[1].via.as_deref(), Some("eval"));
assert_eq!(segs[2].via.as_deref(), Some("sh -c"));
assert_eq!(context_text("git push --force"), "git push --force");
assert_eq!(context_text("ls && rm -rf x"), "ls; rm -rf x");
}
#[test]
fn what_a_shell_wrapper_reading_leaves_alone() {
for cmd in [
"sh deploy.sh",
"bash ./scripts/clean.sh --force",
"sh -c",
"sh -c \"\"",
r#"fish -c "rm -rf ./dist""#,
r#"ksh -c "rm -rf ./dist""#,
r#"python -c "import shutil; shutil.rmtree('dist')""#,
"sh - script.sh",
"bash --norc script.sh",
] {
let segs = split_segments_deep(cmd);
assert_eq!(segs.len(), 1, "{cmd}");
assert_eq!(segs[0].via, None, "{cmd}");
}
let two = r#"sh -c "bash -c 'rm -rf ./dist'""#;
let segs = split_segments_deep(two);
let texts: Vec<&str> = segs.iter().map(|s| &**s).collect();
assert_eq!(texts, [two, "bash -c 'rm -rf ./dist'", "rm -rf ./dist"]);
assert_eq!(segs[1].via.as_deref(), Some("sh -c"));
assert_eq!(segs[2].via.as_deref(), Some("bash -c"));
let mut deep = "rm -rf ./dist".to_string();
for _ in 0..6 {
deep = format!(
"sh -c \"{}\"",
deep.replace('\\', "\\\\").replace('"', "\\\"")
);
}
let segs = split_segments_deep(&deep);
assert_eq!(
segs.len(),
MAX_WRAP_DEPTH + 1,
"one wrapper per level, then stop"
);
assert_ne!(
&*segs[MAX_WRAP_DEPTH], "rm -rf ./dist",
"the innermost string was not reached"
);
assert_eq!(split_segments(two).len(), 1);
}
#[test]
fn sinks_are_not_targets() {
for cmd in [
"cargo test > /dev/null",
"cmd 2> /dev/null",
"make >/dev/null 2>&1",
"cat big > /dev/zero",
"echo x > NUL",
] {
assert!(
redirects(cmd).is_empty(),
"{cmd} truncates a sink, not a file"
);
}
}
#[test]
fn process_substitution_is_not_a_target() {
for cmd in ["tee >(gzip -c) < data", "diff x >(sort)", "cmd > >(bar)"] {
assert!(
redirects(cmd).iter().all(|o| !o.target.starts_with('(')),
"{cmd}: a paren is an operator, not a filename"
);
}
assert!(redirects("tee >(gzip -c) < data").is_empty());
}
#[test]
fn escapes_clobber_and_read_write_open() {
assert!(redirects(r"echo a \> b").is_empty());
let r = redirects("cmd >| forced.txt");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, "forced.txt");
assert!(r[0].truncates);
assert!(redirects("cmd <> rw.txt").is_empty());
}
#[test]
fn an_escaped_quote_does_not_open_a_quote() {
assert_eq!(
split_segments(r#"echo \" ; rm -rf x"#),
vec![r#"echo \""#, "rm -rf x"]
);
assert_eq!(
split_segments(r#"echo "keep \" this; here""#),
vec![r#"echo "keep \" this; here""#]
);
}
#[test]
fn escaped_separators_are_literal() {
assert_eq!(split_segments(r"echo a \; b"), vec![r"echo a \; b"]);
assert_eq!(split_segments(r"echo a\;b"), vec![r"echo a\;b"]);
assert_eq!(
split_segments(r"git commit -m msg \&\& rm x"),
vec![r"git commit -m msg \&\& rm x"]
);
assert_eq!(split_segments("echo a ; b"), vec!["echo a", "b"]);
assert_eq!(split_segments("echo a && rm x"), vec!["echo a", "rm x"]);
let segs = split_segments(r"echo a \; b > out");
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].redirects.len(), 1);
assert_eq!(segs[0].redirects[0].target, "out");
}
#[test]
fn an_escaped_space_keeps_a_target_whole() {
let r = redirects(r"cmd > a\ b.txt");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, r"a\ b.txt");
}
#[test]
fn a_separator_ends_a_target_and_still_splits() {
let segs = split_segments("cmd > log;next");
assert_eq!(segs, vec!["cmd > log", "next"]);
assert_eq!(segs[0].redirects[0].target, "log");
let segs = split_segments("cmd > a&b");
assert_eq!(segs, vec!["cmd > a", "b"]);
assert_eq!(segs[0].redirects[0].target, "a");
}
#[test]
fn the_predicted_survivors_are_killed() {
let r = redirects("> x");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, "x");
assert!(r[0].truncates);
assert!(!redirects(">> log").is_empty());
let segs = split_segments("cmd >>| x");
assert_eq!(segs, vec!["cmd >>", "x"]);
assert!(segs.iter().all(|s| s.redirects.is_empty()));
let r = redirects(r"cmd > a\");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, r"a\");
}
#[test]
fn a_trailing_redirect_with_no_target_pushes_nothing() {
for cmd in ["cmd >", "cmd > ", "cmd >>", "cmd >|", "echo x 2>"] {
assert!(
redirects(cmd).is_empty(),
"{cmd:?} names no target and must push no Overwrite"
);
}
assert!(redirects("echo x \\").is_empty());
}
#[test]
fn several_redirects_in_one_segment() {
let r = redirects("cmd > out.txt 2> err.txt");
assert_eq!(r.len(), 2);
assert_eq!(r[0].target, "out.txt");
assert_eq!(r[1].target, "err.txt");
}
#[test]
fn splits_the_field_report_command() {
let cmd = r#"git status && echo "---" && git branch -vv && git log --oneline -5"#;
let seg = split_segments(cmd);
assert_eq!(
seg,
vec![
"git status",
r#"echo "---""#,
"git branch -vv",
"git log --oneline -5"
]
);
}
#[test]
fn splits_all_operators() {
assert_eq!(
split_segments("a; b | c || d && e"),
vec!["a", "b", "c", "d", "e"]
);
}
#[test]
fn quotes_protect_operators() {
assert_eq!(split_segments("echo 'a && b'"), vec!["echo 'a && b'"]);
assert_eq!(split_segments(r#"echo "x; y""#), vec![r#"echo "x; y""#]);
}
#[test]
fn redirections_survive() {
assert_eq!(split_segments("cmd 2>&1"), vec!["cmd 2>&1"]);
assert_eq!(split_segments("cmd >&2"), vec!["cmd >&2"]);
assert_eq!(split_segments("cmd &> log"), vec!["cmd &> log"]);
assert_eq!(split_segments("cmd &>> log"), vec!["cmd &>> log"]);
assert_eq!(split_segments("cmd <&-"), vec!["cmd <&-"]);
assert_eq!(
split_segments("make 2>&1 | tee log"),
vec!["make 2>&1", "tee log"]
);
assert_eq!(
split_segments("cmd >| forced.txt"),
vec!["cmd >| forced.txt"]
);
assert_eq!(
split_segments("cmd >| out.txt | grep x"),
vec!["cmd >| out.txt", "grep x"]
);
}
#[test]
fn a_lone_ampersand_splits() {
assert_eq!(
split_segments("git status & rm -rf /"),
vec!["git status", "rm -rf /"]
);
assert_eq!(split_segments("ls & rm -rf /"), vec!["ls", "rm -rf /"]);
assert_eq!(split_segments("npm run dev &"), vec!["npm run dev"]);
assert_eq!(split_segments("a & b & c"), vec!["a", "b", "c"]);
assert_eq!(
split_segments("echo hi&rm -rf /"),
vec!["echo hi", "rm -rf /"]
);
}
#[test]
fn newlines_split_too() {
assert_eq!(
split_segments("git status\nrm -rf /"),
vec!["git status", "rm -rf /"]
);
}
#[test]
fn substitution_detected() {
assert!(has_substitution("echo $(rm -rf /)"));
assert!(has_substitution("echo `whoami`"));
assert!(!has_substitution("echo '$(safe)'"));
assert!(!has_substitution("git status"));
}
#[test]
fn a_quote_of_one_kind_does_not_open_the_other() {
let segs = split_segments("echo \"it's fine\" && rm -rf /tmp/x");
assert_eq!(segs.len(), 2, "{segs:?}");
assert!(segs[1].starts_with("rm -rf"), "{segs:?}");
let segs = split_segments("echo 'say \"hi\"' && rm -rf /tmp/x");
assert_eq!(segs.len(), 2, "{segs:?}");
let targets = redirects("echo \"it's\" > out.txt");
assert_eq!(targets.len(), 1, "{targets:?}");
assert_eq!(targets[0].target, "out.txt");
let targets = redirects("echo 'a \"b\"' > out.txt");
assert_eq!(targets[0].target, "out.txt");
}
#[test]
fn single_quotes_hide_a_substitution_and_double_quotes_do_not() {
assert!(!has_substitution("echo 'a `b` c'"));
assert!(has_substitution("echo \"a `b` c\""));
assert!(!has_substitution("echo 'a $(b) c'"));
assert!(has_substitution("echo \"a $(b) c\""));
}
#[test]
fn an_operator_at_the_very_end_is_not_read_past() {
assert_eq!(split_segments("ls &"), ["ls"]);
assert_eq!(split_segments("ls |"), ["ls"]);
assert_eq!(split_segments("ls &&"), ["ls"]);
assert_eq!(split_segments("ls ||"), ["ls"]);
assert_eq!(split_segments("echo \"a\\"), ["echo \"a\\"]);
assert_eq!(redirects("echo a\\").len(), 0);
assert_eq!(redirects("echo > out.txt\\").len(), 1);
}
#[test]
fn a_segment_may_begin_with_the_operator() {
let targets = redirects("> out.txt");
assert_eq!(targets.len(), 1, "{targets:?}");
assert_eq!(targets[0].target, "out.txt");
assert!(targets[0].truncates);
assert_eq!(redirects("&> log.txt").len(), 0);
assert_eq!(split_segments("&> log.txt"), ["&> log.txt"]);
}
#[test]
fn a_redirect_with_nothing_after_it_has_no_target() {
assert_eq!(redirects("echo >").len(), 0);
assert_eq!(redirects("echo > ").len(), 0);
assert_eq!(redirects("echo >>").len(), 0);
}
#[test]
fn a_quoted_target_keeps_the_spaces_inside_it() {
let targets = redirects("echo > 'my file.txt' && ls");
assert_eq!(targets.len(), 1, "{targets:?}");
assert_eq!(targets[0].target, "my file.txt");
let targets = redirects("echo > \"my file.txt\"");
assert_eq!(targets[0].target, "my file.txt");
}
#[test]
fn an_unbalanced_quote_inside_the_other_kind_changes_nothing() {
let segs = split_segments("echo 'it\"s' && rm -rf /tmp/x");
assert_eq!(segs.len(), 2, "{segs:?}");
assert!(segs[1].starts_with("rm -rf"), "{segs:?}");
let targets = redirects("echo 'a\"b' > out.txt");
assert_eq!(
targets.len(),
1,
"the redirect is outside the quotes: {targets:?}"
);
assert_eq!(targets[0].target, "out.txt");
}
#[test]
fn an_escaped_quote_does_not_close_the_string_it_is_inside() {
let segs = split_segments("echo \"a\\\"b && c\"");
assert_eq!(segs.len(), 1, "{segs:?}");
assert_eq!(
segs[0], "echo \"a\\\"b && c\"",
"the text must survive intact"
);
}
#[test]
fn a_backslash_inside_single_quotes_escapes_nothing() {
let targets = redirects("echo '\\' > out.txt");
assert_eq!(targets.len(), 1, "{targets:?}");
assert_eq!(targets[0].target, "out.txt");
}
#[test]
fn an_operator_pair_is_consumed_exactly_once() {
assert_eq!(split_segments("ls &&> out.txt"), ["ls", "> out.txt"]);
assert_eq!(split_segments("ls |grep x"), ["ls", "grep x"]);
assert_eq!(split_segments("ls ||grep x"), ["ls", "grep x"]);
}
#[test]
fn a_segment_may_begin_with_a_backslash() {
assert_eq!(redirects("\\> out.txt").len(), 0);
assert_eq!(split_segments("\\> out.txt"), ["\\> out.txt"]);
}
#[test]
fn every_sink_spelling_is_a_sink() {
for sink in [
"/dev/null",
"/dev/zero",
"/dev/stdout",
"/dev/stderr",
"/dev/tty",
"/dev/full",
"NUL",
"nul",
"/DEV/NULL",
] {
assert!(
redirects(&format!("echo x > {sink}")).is_empty(),
"{sink} destroys nothing and must not be an overwrite target"
);
}
for real in ["/dev/null.bak", "/dev/nullify", "nulled.txt"] {
assert_eq!(
redirects(&format!("echo x > {real}")).len(),
1,
"{real} is an ordinary file"
);
}
}
}