use std::ops::Range;
use crate::rules::{Evidence, Finding, Rule, Stance, Trend};
use crate::shell::{Parsed, Simple};
pub const RULE: Rule = Rule {
id: "pipe-to-tail",
default_stance: Stance::Deny,
evidence: Evidence {
per_1000: 62.3,
measured: "2026-08-20",
trend: Trend::Flat(7),
},
examine,
confirm: None,
};
const MUTATING: &[(&str, &[&str])] = &[
("git", &["push", "commit", "tag"]),
("kubectl", &["apply", "delete", "replace"]),
("helm", &["install", "upgrade"]),
("npm", &["publish"]),
];
const TRIMMING: &[&str] = &["tail", "head", "grep"];
const TAG_LISTING: &[&str] = &[
"-l",
"--list",
"-n",
"--contains",
"--no-contains",
"--points-at",
"--merged",
"--no-merged",
"--sort",
"--format",
"--column",
"--omit-empty",
"-d",
"--delete",
"-v",
"--verify",
];
fn is_mutating(cmd: &Simple) -> bool {
let Some(program) = cmd.program() else {
return false;
};
let Some(sub) = cmd.subcommand() else {
return false;
};
if !MUTATING
.iter()
.any(|(p, subs)| *p == program && subs.contains(&sub))
{
return false;
}
if cmd.is_dry_run() {
return false;
}
if program == "git" && sub == "push" && cmd.has_short('n') {
return false;
}
if program == "git" && sub == "tag" {
return tag_creates(cmd);
}
true
}
fn tag_creates(cmd: &Simple) -> bool {
for w in &cmd.words {
if w.quoted {
continue;
}
let t = w.text.as_str();
if TAG_LISTING
.iter()
.any(|f| t == *f || t.starts_with(&format!("{f}=")))
{
return false;
}
}
cmd.operands().len() > 1
}
fn examine(parsed: &Parsed) -> Option<Finding> {
let clauses = parsed.clauses();
for (i, cmd) in clauses.iter().enumerate() {
if cmd.opaque.is_some() {
continue;
}
if !cmd.next.is_some_and(|c| c.is_pipe()) {
continue;
}
if !is_mutating(cmd) {
continue;
}
let Some(sink) = pipeline_sink(clauses, i) else {
continue;
};
let Some(sink_program) = sink.program() else {
continue;
};
if !TRIMMING.contains(&sink_program) {
continue;
}
let verb = describe(cmd);
return Some(Finding {
reason: format!(
"`{verb}` pipes into `{sink_program}`, so the pipeline reports \
{sink_program}'s exit status, not {verb}'s. A failed, rejected or \
timed-out run reads as success, and the trimming discards the error \
text as well."
),
remedy: format!(
"Run `{verb}` on its own and read its output afterwards. Then verify \
the effect rather than the exit code."
),
span: Range {
start: cmd.at,
end: sink.end,
},
});
}
None
}
fn pipeline_sink(clauses: &[Simple], from: usize) -> Option<&Simple> {
let mut i = from;
while clauses.get(i)?.next.is_some_and(|c| c.is_pipe()) {
i += 1;
}
clauses.get(i)
}
fn describe(cmd: &Simple) -> String {
match (cmd.program(), cmd.subcommand()) {
(Some(p), Some(s)) => format!("{p} {s}"),
(Some(p), None) => p.to_string(),
_ => "the command".to_string(),
}
}