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>, cwd: &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();
let mut uninsurable = false;
for raw in targets.iter().take(3) {
let resolved = resolve_path_in(raw, cwd);
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, cwd) {
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());
uninsurable = true;
}
None => {
lines
.push(" ✗ insurance : no backup covers this command — NOT recoverable".into());
worst_first.push("NOT recoverable".into());
uninsurable = true;
}
}
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,
uninsurable,
})
}
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 Some((head, at)) = resolve_head(&tokens) else {
continue;
};
if !is_delete_command(&head) {
continue;
}
for t in tokens.iter().skip(at + 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),
"copy" | "move" | "xcopy" | "robocopy" => 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()
}
const COMMAND_WRAPPERS: [&str; 6] = ["sudo", "doas", "env", "command", "nohup", "nice"];
fn wrapper_flag_takes_value(wrapper: &str, flag: &str) -> bool {
match wrapper {
"sudo" | "doas" => matches!(
flag,
"-u" | "-g" | "-p" | "-C" | "-D" | "-h" | "-R" | "-T" | "--user" | "--group"
),
"env" => matches!(flag, "-u" | "--unset" | "-S" | "--split-string"),
"nice" => matches!(flag, "-n" | "--adjustment"),
_ => false,
}
}
pub fn resolve_head(tokens: &[String]) -> Option<(String, usize)> {
let mut i = 0;
loop {
let head = command_head(tokens.get(i)?);
if !COMMAND_WRAPPERS.contains(&head.as_str()) {
return Some((head, i));
}
let wrapper = head;
i += 1;
while let Some(tok) = tokens.get(i) {
if tok.starts_with('-') {
if wrapper_flag_takes_value(&wrapper, tok) {
i += 1;
}
i += 1;
} else if is_env_assignment(tok) {
i += 1;
} else {
break;
}
}
}
}
fn is_env_assignment(token: &str) -> bool {
match token.split_once('=') {
Some((name, _)) => {
!name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !name.chars().next().is_some_and(|c| c.is_ascii_digit())
}
None => false,
}
}
pub fn is_delete_command(head: &str) -> bool {
matches!(
head,
"rm" | "rmdir" | "del" | "rd" | "unlink" | "remove-item" | "ri"
)
}
pub fn tokenize_public(s: &str) -> Vec<String> {
tokenize(s)
}
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_in(raw: &str, cwd: &Path) -> 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 { cwd.join(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 = canonical_prefix(p);
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)
}
}
fn canonical_prefix(p: &Path) -> PathBuf {
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut cur = p.to_path_buf();
loop {
if let Ok(c) = cur.canonicalize() {
let mut out = c;
for part in tail.iter().rev() {
out.push(part);
}
return out;
}
match (cur.file_name(), cur.parent()) {
(Some(name), Some(parent)) => {
tail.push(name.to_os_string());
cur = parent.to_path_buf();
}
_ => return p.to_path_buf(),
}
}
}
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() {
if files >= MAX_FILES || start.elapsed() > MAX_TIME {
return Scan {
files,
dirs,
capped: true,
};
}
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 {
const MAX: usize = 40;
let n = p.chars().count();
if n <= MAX {
return p.to_string();
}
let tail: String = p.chars().skip(n - (MAX - 1)).collect();
format!("…{tail}")
}
#[cfg(test)]
mod tests {
use super::*;
fn resolve_path_here(raw: &str) -> PathBuf {
resolve_path_in(raw, &std::env::current_dir().unwrap())
}
use crate::testutil::TempTree;
#[test]
fn short_never_panics_on_non_ascii_paths() {
for prefix in [
"/home/Пользователь/",
"/home/user/用户文档/",
"/home/Jörg/Bücher/",
"C:\\Users\\Пользователь\\Рабочий стол\\",
"/home/user/📁/",
] {
for n in 0..80 {
let p = format!("{prefix}{}", "a".repeat(n));
let s = short(&p);
assert!(s.chars().count() <= 40, "not trimmed: {s}");
if p.chars().count() > 40 {
assert!(s.starts_with('…'), "expected ellipsis: {s}");
assert!(p.ends_with(
&s[s.char_indices().nth(1).map(|(i, _)| i).unwrap_or(s.len())..]
));
}
}
}
}
#[test]
fn short_leaves_ascii_behaviour_unchanged() {
assert_eq!(short("/tmp/x"), "/tmp/x");
let long = format!("/home/user/{}", "a".repeat(60));
assert_eq!(short(&long).chars().count(), 40);
assert!(short(&long).ends_with("aaa"));
}
#[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_here("/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_here("/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_here("/usr/local/lib"),
PathBuf::from("/usr/local/lib")
);
assert_eq!(resolve_path_here("/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_here("./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_here("../sibling");
assert!(
!is_inside(&up, &root),
"../sibling must be outside the cwd, got {}",
up.display()
);
let b = resolve_path_here("build");
assert!(
is_inside(&b, &root),
"a non-existent in-project path must still be inside, got {}",
b.display()
);
assert!(
is_inside(&resolve_path_here("definitely-not-created-yet"), &root),
"existence must not change the inside/outside answer"
);
}
#[test]
fn quotes_are_stripped_before_resolution() {
assert_eq!(
resolve_path_here("\"/c/tmp\"")
.to_string_lossy()
.to_lowercase(),
resolve_path_here("/c/tmp").to_string_lossy().to_lowercase()
);
assert_eq!(
resolve_path_here("'/tmp/x'").to_string_lossy(),
resolve_path_here("/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")));
}
#[cfg(unix)]
#[test]
fn a_target_that_does_not_exist_yet_is_still_inside_a_symlinked_root() {
let t = TempTree::new("symlink-root");
let real = t.dir("real");
let link = t.path().join("link");
std::os::unix::fs::symlink(&real, &link).expect("symlink must be creatable");
assert!(
is_inside(&link.join("not-created-yet"), &link),
"an absent child is still inside its parent"
);
assert!(
is_inside(&link.join("a/b/c/d"), &link),
"however deep, and however much of it exists"
);
let present = t.dir("real/present");
assert!(is_inside(&present, &link));
assert!(
!is_inside(t.path().join("elsewhere").as_path(), &link),
"a sibling of the root is not inside it"
);
}
#[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 tmp = TempTree::new("scan");
let dir = tmp.path().to_path_buf();
let sub = tmp.dir("nested");
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 a_single_large_directory_cannot_blow_the_budget() {
let tmp = TempTree::new("scan-flat");
let dir = tmp.path().to_path_buf();
for i in 0..(MAX_FILES + 500) {
std::fs::write(dir.join(format!("f{i}")), "").unwrap();
}
let scan = scan_budgeted(&dir);
assert!(
scan.capped,
"a flat directory larger than the budget must report capped"
);
assert!(
scan.files <= MAX_FILES,
"the count must stop at the budget, not at the directory's end: {}",
scan.files
);
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");
}
use crate::testutil::TestEnv;
fn preview_lines(command: &str, root: Option<&Path>) -> Vec<String> {
preview_for(command, root, &std::env::current_dir().unwrap())
.map(|p| p.lines)
.unwrap_or_default()
}
fn has_line(lines: &[String], needle: &str) -> bool {
lines.iter().any(|l| l.contains(needle))
}
#[test]
fn the_as_written_line_appears_only_when_resolution_changed_something() {
let env = TestEnv::new("del-written");
let home = env.root().join("home");
std::fs::create_dir_all(&home).expect("home must be creatable");
let _guard = HomeGuard::set(Some(&home));
let tmp = TempTree::new("del-written-tree");
let dir = tmp.dir("target");
let lines = preview_lines(&format!("rm -rf {}", dir.display()), None);
assert!(!has_line(&lines, "as written"), "{lines:?}");
let lines = preview_lines("rm -rf ./some-relative-target", None);
assert!(!has_line(&lines, "as written"), "{lines:?}");
let lines = preview_lines("rm -rf ~/some-home-target", None);
assert!(
has_line(&lines, "as written"),
"an expansion the reader would miss has to be shown: {lines:?}"
);
}
#[test]
fn a_target_outside_the_project_root_is_called_out() {
let tmp = TempTree::new("del-outside");
let root = tmp.dir("project");
let inside = tmp.dir("project/build");
let outside = tmp.dir("elsewhere");
let lines = preview_lines(&format!("rm -rf {}", outside.display()), Some(&root));
assert!(has_line(&lines, "OUTSIDE the project root"), "{lines:?}");
let lines = preview_lines(&format!("rm -rf {}", inside.display()), Some(&root));
assert!(
!has_line(&lines, "OUTSIDE the project root"),
"a warning that fires on ordinary work is one nobody reads: {lines:?}"
);
}
#[test]
fn a_target_that_does_not_exist_says_so_instead_of_counting() {
let tmp = TempTree::new("del-absent");
let missing = tmp.absent("not-here");
let present = tmp.dir("here");
let lines = preview_lines(&format!("rm -rf {}", missing.display()), None);
assert!(has_line(&lines, "does not exist"), "{lines:?}");
assert!(!has_line(&lines, "files across"), "{lines:?}");
let lines = preview_lines(&format!("rm -rf {}", present.display()), None);
assert!(!has_line(&lines, "does not exist"), "{lines:?}");
assert!(has_line(&lines, "files across"), "{lines:?}");
}
#[test]
fn an_insurable_delete_within_budget_reports_its_insurance() {
let tmp = TempTree::new("del-insurance");
let dir = tmp.dir("small");
std::fs::write(dir.join("a.txt"), "a").expect("file must be writable");
let lines = preview_lines(&format!("rm -rf {}", dir.display()), None);
assert!(
has_line(&lines, "insurance :"),
"a small delete is recoverable and should say so: {lines:?}"
);
assert!(
!has_line(&lines, "too large to copy"),
"nothing here is too large: {lines:?}"
);
}
#[test]
fn a_delete_too_large_to_copy_says_that_rather_than_naming_insurance() {
let tmp = TempTree::new("del-too-large");
let dir = tmp.dir("huge");
for i in 0..(MAX_FILES + 100) {
std::fs::write(dir.join(format!("f{i}")), "").expect("file must be writable");
}
let lines = preview_lines(&format!("rm -rf {}", dir.display()), None);
assert!(
has_line(&lines, "too large to copy"),
"an insurable command over budget must say which fact applies: {lines:?}"
);
assert!(
!has_line(&lines, "insurance :"),
"it cannot both promise insurance and say it is out of reach: {lines:?}"
);
assert!(
has_line(&lines, "+ files (stopped counting)"),
"the count says it stopped: {lines:?}"
);
}
#[test]
fn a_cmd_switch_is_matched_by_shape_not_by_its_leading_slash() {
for switch in ["/s", "/q", "/f", "/a:h", "/a:"] {
assert!(is_cmd_switch(switch), "{switch} is a cmd switch");
}
for path in ["/tmp", "/usr", "/c/Users", "/a:hidden", "notaswitch"] {
assert!(!is_cmd_switch(path), "{path} is a path");
}
}
#[test]
fn a_drive_letter_is_split_only_when_it_really_is_one() {
assert_eq!(split_drive("c/Users/x"), Some(("c", "Users/x")));
assert_eq!(split_drive("c"), Some(("c", "")));
assert_eq!(split_drive("usr/local/lib"), None);
assert_eq!(split_drive("1/x"), None);
}
#[test]
fn a_tilde_is_expanded_to_the_home_it_names() {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.expect("a home directory must be set");
let home = PathBuf::from(home);
assert_eq!(resolve_path_here("~"), home);
assert_eq!(resolve_path_here("~/projects"), home.join("projects"));
assert_ne!(resolve_path_here("~notauser"), home.join("notauser"));
}
struct HomeGuard {
home: Option<std::ffi::OsString>,
profile: Option<std::ffi::OsString>,
}
impl HomeGuard {
fn set(home: Option<&Path>) -> Self {
let guard = HomeGuard {
home: std::env::var_os("HOME"),
profile: std::env::var_os("USERPROFILE"),
};
std::env::remove_var("USERPROFILE");
match home {
Some(p) => std::env::set_var("HOME", p),
None => std::env::remove_var("HOME"),
}
guard
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.home.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match self.profile.take() {
Some(v) => std::env::set_var("USERPROFILE", v),
None => std::env::remove_var("USERPROFILE"),
}
}
}
#[test]
fn a_home_and_its_siblings_are_recognised_as_profiles() {
let env = TestEnv::new("del-profile");
let users = env.root().join("home");
let alice = users.join("alice");
std::fs::create_dir_all(&alice).expect("home must be creatable");
let _guard = HomeGuard::set(Some(&alice));
assert!(is_user_profile(&alice), "the home itself");
assert!(
is_user_profile(&users.join("bob")),
"a sibling profile: an agent on a mistyped path lands here"
);
assert!(
!is_user_profile(&alice.join("projects")),
"something inside a profile is not the profile"
);
assert!(
!is_user_profile(&env.root().join("etc")),
"a different parent is a different thing"
);
let same_depth_elsewhere = env.root().join("srv").join("bob");
assert!(
!is_user_profile(&same_depth_elsewhere),
"{}",
same_depth_elsewhere.display()
);
}
#[test]
fn the_profile_shapes_are_recognised_even_with_no_home_set() {
let _env = TestEnv::new("del-profile-fallback");
let _guard = HomeGuard::set(None);
assert!(is_user_profile(Path::new("/home/alice")));
assert!(is_user_profile(Path::new("/Users/alice")));
assert!(is_user_profile(Path::new("C:\\Users\\alice")));
assert!(!is_user_profile(Path::new("/home/alice/projects")));
assert!(!is_user_profile(Path::new("/etc")));
}
#[test]
fn sensitive_children_are_named_whatever_their_casing() {
let tmp = TempTree::new("del-sensitive");
let dir = tmp.dir("home");
std::fs::create_dir_all(dir.join(".SSH")).expect("dir must be creatable");
std::fs::write(dir.join(".env"), "SECRET=1").expect("file must be writable");
std::fs::write(dir.join("notes.txt"), "hello").expect("file must be writable");
let found = sensitive_children(&dir);
let names: Vec<&str> = found.iter().map(|(n, _)| n.as_str()).collect();
assert!(
names.contains(&".SSH"),
"an upper-case spelling holds the same keys: {names:?}"
);
assert!(names.contains(&".env"), "{names:?}");
assert_eq!(
found.len(),
2,
"two different sensitive entries are two findings: {names:?}"
);
assert!(
!names.contains(&"notes.txt"),
"ordinary files are not findings: {names:?}"
);
}
}