use super::project_delete::is_project_target;
use super::shell::{command_basename, unwrapped_command_index};
use super::{path_escapes_root, PROJECT_DELETE_REASON};
const SELECTING: &[(&str, usize)] = &[
("-amin", 1),
("-anewer", 1),
("-atime", 1),
("-cmin", 1),
("-cnewer", 1),
("-context", 1),
("-ctime", 1),
("-empty", 0),
("-executable", 0),
("-fstype", 1),
("-gid", 1),
("-group", 1),
("-ilname", 1),
("-iname", 1),
("-inum", 1),
("-ipath", 1),
("-iregex", 1),
("-iwholename", 1),
("-links", 1),
("-lname", 1),
("-mmin", 1),
("-mtime", 1),
("-name", 1),
("-newer", 1),
("-nogroup", 0),
("-nouser", 0),
("-path", 1),
("-perm", 1),
("-readable", 0),
("-regex", 1),
("-samefile", 1),
("-size", 1),
("-type", 1),
("-uid", 1),
("-used", 1),
("-user", 1),
("-wholename", 1),
("-writable", 0),
("-xtype", 1),
];
const OPERAND_ONLY: &[(&str, usize)] = &[
("-fls", 1),
("-fprint", 1),
("-fprint0", 1),
("-fprintf", 2),
("-maxdepth", 1),
("-mindepth", 1),
("-printf", 1),
("-regextype", 1),
];
fn primary_shape(token: &str) -> Option<(bool, usize)> {
if let Some((_, count)) = SELECTING.iter().find(|(name, _)| *name == token) {
return Some((true, *count));
}
if token.starts_with("-newer") && token.len() == 8 {
return Some((true, 1));
}
OPERAND_ONLY
.iter()
.find(|(name, _)| *name == token)
.map(|(_, count)| (false, *count))
}
const EXEC_ACTIONS: &[&str] = &["-exec", "-execdir", "-ok", "-okdir"];
pub(super) fn find_delete_catastrophe(args: &[String], roots: &[String]) -> Option<String> {
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"-D" => index += 2,
option if matches!(option, "-H" | "-L" | "-P") || option.starts_with("-O") => {
index += 1;
}
_ => break,
}
}
let mut starts = Vec::new();
while index < args.len() && !is_expression_start(&args[index]) {
starts.push(args[index].as_str());
index += 1;
}
if starts.is_empty() {
starts.push(".");
}
let expression = &args[index..];
let mut deletes = false;
let mut filtered = false;
let mut unselective = false;
let mut unknown_starts = false;
let mut position = 0;
while position < expression.len() {
let token = expression[position].as_str();
if token == "-delete" {
deletes = true;
} else if EXEC_ACTIONS.contains(&token) {
let end = expression[position + 1..]
.iter()
.position(|arg| arg == ";" || arg == "+")
.map_or(expression.len(), |offset| position + 1 + offset);
let program = &expression[position + 1..end];
let command = unwrapped_command_index(program, 0, program.len());
deletes = deletes
|| program
.get(command)
.is_some_and(|word| command_basename(word) == "rm");
position = end + 1;
continue;
} else if !deletes && matches!(token, "-o" | "-or" | "," | "!" | "-not" | "-false") {
unselective = true;
} else if token == "-files0-from" {
unknown_starts = true;
position += 2;
continue;
} else if let Some((selects, operands)) = primary_shape(token) {
filtered |= selects && !deletes;
position += 1 + operands;
continue;
}
position += 1;
}
if !deletes {
return None;
}
if unknown_starts {
return Some(
"`find -files0-from … -delete` is blocked: its start paths are read from a file, so the delete cannot be confined to the workspace."
.to_string(),
);
}
if let Some(start) = starts.iter().find(|start| path_escapes_root(start, roots)) {
return Some(format!(
"`find {start} … -delete` is blocked: it deletes under an absolute path or outside the project root. Search and delete only inside the workspace."
));
}
((unselective || !filtered) && starts.iter().any(|start| is_project_target(start)))
.then(|| PROJECT_DELETE_REASON.to_string())
}
fn is_expression_start(token: &str) -> bool {
token.starts_with('-') || matches!(token, "(" | ")" | "!" | ",")
}