use crate::preview::Preview;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
const MAX_FILES: usize = 5_000;
const MAX_TIME: Duration = Duration::from_millis(300);
const SENSITIVE: &[(&str, &str)] = &[
(".ssh", "SSH private keys"),
(".aws", "AWS credentials"),
(".gnupg", "GPG keys"),
(".kube", "Kubernetes credentials"),
(".docker", "Docker credentials"),
(".env", "environment secrets"),
(".git", "git history"),
("id_rsa", "SSH private key"),
("id_ed25519", "SSH private key"),
("credentials", "credentials file"),
(".npmrc", "npm tokens"),
(".pypirc", "PyPI tokens"),
(".netrc", "stored logins"),
];
pub fn preview_for(command: &str, project_root: Option<&Path>) -> Option<Preview> {
let targets = extract_targets(command);
if targets.is_empty() {
return None;
}
let mut lines = Vec::new();
let mut summary_parts: Vec<String> = Vec::new();
let mut worst_first: Vec<String> = Vec::new();
for raw in targets.iter().take(3) {
let resolved = resolve_path(raw);
let display = resolved.display().to_string();
lines.push(format!(" target : {}", display));
let raw_norm = raw.trim_matches('"').replace('\\', "/").to_lowercase();
let disp_norm = display.replace('\\', "/").to_lowercase();
let cosmetic = raw_norm == disp_norm
|| disp_norm.ends_with(&raw_norm.trim_start_matches("./").to_string());
if !cosmetic {
lines.push(format!(" as written : {}", raw));
}
if let Some(root) = project_root {
if !is_inside(&resolved, root) {
lines.push(format!(" ⚠ OUTSIDE the project root ({})", root.display()));
worst_first.push("OUTSIDE project root".into());
}
}
if is_user_profile(&resolved) {
lines.push(" ⚠ resolves to a USER PROFILE directory".into());
worst_first.push("resolves to a user profile".into());
}
if is_filesystem_root(&resolved) {
lines.push(" ⚠ resolves to a FILESYSTEM ROOT".into());
worst_first.push("resolves to a filesystem root".into());
}
if !resolved.exists() {
lines.push(" contains : (path does not exist — nothing to delete)".into());
summary_parts.push(format!("{} does not exist", short(&display)));
continue;
}
let notable = sensitive_children(&resolved);
if !notable.is_empty() {
let names: Vec<String> = notable
.iter()
.map(|(n, w)| format!("{} ({})", n, w))
.collect();
lines.push(format!(" ⚠ contains : {}", names.join(", ")));
worst_first.push(format!("contains {}", notable[0].0));
}
let scan = scan_budgeted(&resolved);
let count_str = if scan.capped {
format!("{}+ files (stopped counting)", fmt_num(scan.files))
} else {
format!("{} files", fmt_num(scan.files))
};
lines.push(format!(
" contains : {} across {} director{}",
count_str,
fmt_num(scan.dirs),
if scan.dirs == 1 { "y" } else { "ies" }
));
match crate::backup::plan(command) {
Some(plan) if !scan.capped => {
lines.push(format!(" insurance : {} (automatic on run/hook)", plan));
}
Some(_) => {
lines.push(format!(
" ✗ insurance : too large to copy ({}+ files) — NOT recoverable",
fmt_num(MAX_FILES)
));
worst_first.push("NOT recoverable".into());
}
None => {
lines
.push(" ✗ insurance : no backup covers this command — NOT recoverable".into());
worst_first.push("NOT recoverable".into());
}
}
summary_parts.push(format!("{} — {}", short(&display), count_str));
}
worst_first.dedup();
let summary = if worst_first.is_empty() {
summary_parts.join("; ")
} else {
format!("{} — {}", worst_first.join(", "), summary_parts.join("; "))
};
Some(Preview {
title: "delete impact".into(),
lines,
summary,
})
}
pub fn extract_targets(command: &str) -> Vec<String> {
let mut out = Vec::new();
for segment in crate::shell::split_segments(command) {
let tokens = tokenize(&segment);
if tokens.is_empty() {
continue;
}
let head = command_head(&tokens[0]);
if !is_delete_command(&head) {
continue;
}
for t in tokens.iter().skip(1) {
if is_flag(&head, t) {
continue;
}
out.push(t.clone());
}
}
out
}
pub fn is_flag(head: &str, token: &str) -> bool {
match head {
"rm" | "unlink" => token.starts_with('-'),
"remove-item" | "ri" => token.starts_with('-'),
"del" | "rd" | "rmdir" => token.starts_with('-') || is_cmd_switch(token),
_ => token.starts_with('-'),
}
}
fn is_cmd_switch(token: &str) -> bool {
if !token.starts_with('/') {
return false;
}
token.len() == 2 || (token.len() <= 4 && token.starts_with("/a:"))
}
pub fn command_head(token: &str) -> String {
let lower = token.to_ascii_lowercase();
lower
.rsplit(['/', '\\'])
.next()
.unwrap_or(&lower)
.trim_end_matches(".exe")
.to_string()
}
pub fn is_delete_command(head: &str) -> bool {
matches!(
head,
"rm" | "rmdir" | "del" | "rd" | "unlink" | "remove-item" | "ri"
)
}
fn tokenize(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
for c in s.chars() {
match (quote, c) {
(Some(q), ch) if ch == q => quote = None,
(Some(_), ch) => cur.push(ch),
(None, '"') | (None, '\'') => quote = Some(c),
(None, ch) if ch.is_whitespace() => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
(None, ch) => cur.push(ch),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
pub fn resolve_path(raw: &str) -> PathBuf {
let s = raw.trim().trim_matches('"').trim_matches('\'');
if let Some(rest) = s.strip_prefix("/mnt/") {
if let Some((drive, tail)) = split_drive(rest) {
return PathBuf::from(format!("{}:\\{}", drive.to_ascii_uppercase(), tail));
}
}
if let Some(rest) = s.strip_prefix('/') {
if let Some((drive, tail)) = split_drive(rest) {
let root = format!("{}:\\", drive.to_ascii_uppercase());
if Path::new(&root).exists() {
return PathBuf::from(format!("{}{}", root, tail.replace('/', "\\")));
}
}
}
if s == "~" || s.starts_with("~/") || s.starts_with("~\\") {
if let Some(home) = home_dir() {
let tail = s.trim_start_matches('~').trim_start_matches(['/', '\\']);
return if tail.is_empty() {
home
} else {
home.join(tail)
};
}
}
let p = PathBuf::from(s);
let joined = if p.is_absolute() {
p
} else if let Ok(cwd) = std::env::current_dir() {
cwd.join(p)
} else {
p
};
normalise(joined)
}
fn normalise(p: PathBuf) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
Component::Prefix(pre) => out.push(pre.as_os_str()),
Component::RootDir => {
let mut buf = out.into_os_string();
buf.push(std::path::MAIN_SEPARATOR.to_string());
out = PathBuf::from(buf);
}
Component::Normal(n) => out.push(n),
}
}
out
}
fn split_drive(s: &str) -> Option<(&str, &str)> {
let mut parts = s.splitn(2, '/');
let head = parts.next()?;
if head.len() != 1 || !head.chars().next()?.is_ascii_alphabetic() {
return None;
}
Some((head, parts.next().unwrap_or("")))
}
fn home_dir() -> Option<PathBuf> {
std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.ok()
.map(PathBuf::from)
}
pub fn is_inside(target: &Path, root: &Path) -> bool {
let t = for_compare(target);
let r = for_compare(root);
t.starts_with(&r)
}
fn for_compare(p: &Path) -> PathBuf {
let c = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
let s = c.to_string_lossy().to_string();
let s = s.strip_prefix(r"\\?\").unwrap_or(&s).to_string();
if cfg!(windows) {
PathBuf::from(s.to_lowercase())
} else {
PathBuf::from(s)
}
}
pub fn is_user_profile(p: &Path) -> bool {
if let Some(home) = home_dir() {
if p == home {
return true;
}
if let Some(parent) = home.parent() {
if p.parent() == Some(parent) && p.components().count() == home.components().count() {
return true;
}
}
}
let s = p.to_string_lossy().replace('\\', "/").to_lowercase();
let depth = s.trim_end_matches('/').matches('/').count();
(s.contains("/users/") && depth <= 2) || (s.starts_with("/home/") && depth == 2)
}
pub fn is_filesystem_root(p: &Path) -> bool {
p.parent().is_none()
|| matches!(
p.to_string_lossy().as_ref(),
"/" | "C:\\" | "c:\\" | "C:/" | "c:/"
)
}
fn sensitive_children(dir: &Path) -> Vec<(String, &'static str)> {
let mut found = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return found;
};
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().to_string();
for (needle, what) in SENSITIVE {
if name.eq_ignore_ascii_case(needle) || name.starts_with(needle) {
found.push((name.clone(), *what));
break;
}
}
}
found.sort();
found.dedup_by(|a, b| a.0 == b.0);
found
}
pub struct Scan {
pub files: usize,
pub dirs: usize,
pub capped: bool,
}
pub fn scan_budgeted(root: &Path) -> Scan {
let start = Instant::now();
let mut files = 0usize;
let mut dirs = 0usize;
let mut stack = vec![root.to_path_buf()];
if root.is_file() {
return Scan {
files: 1,
dirs: 0,
capped: false,
};
}
while let Some(dir) = stack.pop() {
if files >= MAX_FILES || start.elapsed() > MAX_TIME {
return Scan {
files,
dirs,
capped: true,
};
}
let Ok(entries) = std::fs::read_dir(&dir) else {
continue; };
dirs += 1;
for e in entries.flatten() {
match e.path().symlink_metadata() {
Ok(m) if m.is_dir() => stack.push(e.path()),
Ok(_) => files += 1,
Err(_) => {}
}
}
}
Scan {
files,
dirs,
capped: false,
}
}
fn fmt_num(n: usize) -> String {
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
fn short(p: &str) -> String {
if p.len() <= 40 {
return p.to_string();
}
format!("…{}", &p[p.len() - 39..])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_targets_across_shells() {
assert_eq!(
extract_targets("rm -rf /c/Users/harih"),
vec!["/c/Users/harih"]
);
assert_eq!(extract_targets("rm -rf ./build"), vec!["./build"]);
assert_eq!(
extract_targets("Remove-Item -Recurse -Force C:\\tmp\\x"),
vec!["C:\\tmp\\x"]
);
assert_eq!(extract_targets("del /s /q C:\\tmp"), vec!["C:\\tmp"]);
assert_eq!(extract_targets("rmdir /s C:\\tmp"), vec!["C:\\tmp"]);
assert_eq!(
extract_targets(r#"rm -rf "/c/Users/my name/x""#),
vec!["/c/Users/my name/x"]
);
assert!(extract_targets("git status").is_empty());
assert!(extract_targets("ls -la /c/Users").is_empty());
}
#[test]
fn flag_syntax_is_decided_per_shell_not_by_shape() {
assert!(is_flag("del", "/s"));
assert!(is_flag("rd", "/q"));
assert!(!is_flag("rm", "/c"));
assert!(!is_flag("rm", "/mnt/c/data"));
assert!(is_flag("rm", "-rf"));
assert!(is_flag("remove-item", "-Recurse"));
assert!(!is_flag("remove-item", "C:\\tmp"));
assert!(is_flag("rmdir", "/s"));
assert!(is_flag("rmdir", "-p"));
assert!(!is_flag("rmdir", "/c/data"));
assert!(!is_flag("rmdir", "./build"));
assert!(!is_flag("del", "/tmp/x"));
assert!(!is_flag("rd", "/var/lib/thing"));
assert!(is_flag("del", "/a:h"));
assert!(!is_flag("del", "/a/b/c"));
}
#[test]
fn whole_drive_delete_is_not_mistaken_for_a_flag() {
assert_eq!(extract_targets("rm -rf /c"), vec!["/c"]);
assert_eq!(extract_targets("rm -rf /"), vec!["/"]);
}
#[test]
fn command_heads_normalise() {
assert_eq!(command_head("rm"), "rm");
assert_eq!(command_head("/bin/rm"), "rm");
assert_eq!(command_head("C:\\Windows\\System32\\del.exe"), "del");
assert_eq!(command_head("Remove-Item"), "remove-item");
}
#[test]
fn extracts_from_compound_segments() {
let t = extract_targets("git status && rm -rf ./dist");
assert_eq!(t, vec!["./dist"]);
}
#[test]
fn resolves_git_bash_paths_only_when_the_drive_exists() {
#[cfg(windows)]
{
let p = resolve_path("/c/Users/harih");
let s = p.to_string_lossy().to_lowercase();
assert!(
s.starts_with("c:") && s.contains("users"),
"git-bash path must resolve to a Windows path, got {s}"
);
}
#[cfg(windows)]
{
let u = resolve_path("/usr/local/lib")
.to_string_lossy()
.to_lowercase();
assert!(
!u.starts_with("u:"),
"/usr must not become drive U:, got {u}"
);
assert!(u.contains("usr"), "the path must survive intact, got {u}");
}
#[cfg(unix)]
{
assert_eq!(
resolve_path("/usr/local/lib"),
PathBuf::from("/usr/local/lib")
);
assert_eq!(resolve_path("/etc/hosts"), PathBuf::from("/etc/hosts"));
}
}
#[test]
fn relative_paths_normalise_and_stay_inside_the_project() {
let root = std::env::current_dir().unwrap();
let t = resolve_path("./target");
let s = t.to_string_lossy().to_string();
assert!(
!s.contains("/./") && !s.contains("\\.\\") && !s.ends_with("/."),
"dot components must be collapsed, got {s}"
);
assert!(
is_inside(&t, &root),
"./target must be inside the cwd, got {s}"
);
let up = resolve_path("../sibling");
assert!(
!is_inside(&up, &root),
"../sibling must be outside the cwd, got {}",
up.display()
);
let b = resolve_path("build");
assert!(
is_inside(&b, &root),
"a non-existent in-project path must still be inside, got {}",
b.display()
);
assert!(
is_inside(&resolve_path("definitely-not-created-yet"), &root),
"existence must not change the inside/outside answer"
);
}
#[test]
fn quotes_are_stripped_before_resolution() {
assert_eq!(
resolve_path("\"/c/tmp\"").to_string_lossy().to_lowercase(),
resolve_path("/c/tmp").to_string_lossy().to_lowercase()
);
assert_eq!(
resolve_path("'/tmp/x'").to_string_lossy(),
resolve_path("/tmp/x").to_string_lossy()
);
}
#[test]
fn filesystem_roots_are_recognised() {
assert!(is_filesystem_root(Path::new("/")));
assert!(is_filesystem_root(Path::new("C:\\")));
assert!(!is_filesystem_root(Path::new("/usr")));
}
#[test]
fn inside_is_lexical_when_paths_do_not_exist() {
let root = Path::new("/proj");
assert!(is_inside(Path::new("/proj/src"), root));
assert!(!is_inside(Path::new("/other/src"), root));
}
#[test]
fn budgeted_scan_counts_and_reports_capping_honestly() {
let dir = std::env::temp_dir().join(format!("tmx-scan-{}", std::process::id()));
let sub = dir.join("nested");
std::fs::create_dir_all(&sub).unwrap();
for i in 0..12 {
std::fs::write(dir.join(format!("f{i}.txt")), "x").unwrap();
}
for i in 0..5 {
std::fs::write(sub.join(format!("g{i}.txt")), "x").unwrap();
}
let scan = scan_budgeted(&dir);
assert_eq!(scan.files, 17, "must count files recursively");
assert_eq!(scan.dirs, 2);
assert!(!scan.capped, "17 files is well under the budget");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn number_formatting_matches_the_incident() {
assert_eq!(fmt_num(70201), "70,201");
assert_eq!(fmt_num(999), "999");
assert_eq!(fmt_num(1000), "1,000");
assert_eq!(fmt_num(0), "0");
}
}