use std::path::Path;
use crate::fs::{platform, FsRoot};
#[derive(Debug, Default)]
pub struct TreeOutcome {
pub removed: u64,
pub bytes: u64,
pub entries: Vec<String>,
pub truncated: bool,
pub failures: Vec<String>,
}
pub fn remove_tree(root: &FsRoot, target: &Path, dry_run: bool, limit: usize) -> TreeOutcome {
let mut outcome = TreeOutcome::default();
visit(root, target, dry_run, limit, &mut outcome);
outcome
}
fn visit(root: &FsRoot, path: &Path, dry_run: bool, limit: usize, out: &mut TreeOutcome) {
let Ok(meta) = std::fs::symlink_metadata(path) else {
out.failures.push(name_of(root, path));
return;
};
if meta.is_dir() {
match std::fs::read_dir(path) {
Ok(entries) => {
for entry in entries {
visit_entry(root, path, entry, dry_run, limit, out);
}
}
Err(_) => {
out.failures.push(name_of(root, path));
return;
}
}
}
out.removed += 1;
if !meta.is_dir() {
out.bytes += meta.len();
}
if out.entries.len() < limit {
out.entries.push(name_of(root, path));
} else {
out.truncated = true;
}
if !dry_run {
let result = if meta.is_dir() {
std::fs::remove_dir(path)
} else {
platform::remove_entry(path, &meta)
};
if result.is_err() {
out.failures.push(name_of(root, path));
}
}
}
fn visit_entry(
root: &FsRoot,
parent: &Path,
entry: std::io::Result<std::fs::DirEntry>,
dry_run: bool,
limit: usize,
out: &mut TreeOutcome,
) {
match entry {
Ok(entry) => visit(root, &entry.path(), dry_run, limit, out),
Err(_) => out.failures.push(unreadable_entry_name(root, parent)),
}
}
fn unreadable_entry_name(root: &FsRoot, parent: &Path) -> String {
let parent_name = name_of(root, parent);
if parent_name.is_empty() {
"<unreadable entry>".to_string()
} else {
format!("{parent_name}/<unreadable entry>")
}
}
fn name_of(root: &FsRoot, path: &Path) -> String {
root.relative(path)
.unwrap_or_else(|| path.display().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::FsRoot;
fn tree(files: &[&str]) -> (tempfile::TempDir, FsRoot) {
let dir = tempfile::tempdir().expect("tempdir");
for file in files {
let path = dir.path().join(file);
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(&path, b"xy").expect("write");
}
let root = FsRoot::new(dir.path()).expect("root");
(dir, root)
}
#[test]
fn a_dry_run_counts_everything_and_removes_nothing() {
let (dir, root) = tree(&["app/a.txt", "app/deep/b.txt"]);
let target = root.resolve_existing("app").expect("resolve");
let outcome = remove_tree(&root, &target, true, 100);
assert_eq!(outcome.removed, 4);
assert_eq!(outcome.bytes, 4, "두 파일 × 2바이트");
assert!(outcome.failures.is_empty());
assert!(dir.path().join("app/a.txt").exists());
assert!(dir.path().join("app/deep/b.txt").exists());
}
#[test]
fn a_real_run_removes_the_whole_tree() {
let (dir, root) = tree(&["app/a.txt", "app/deep/b.txt"]);
let target = root.resolve_existing("app").expect("resolve");
let outcome = remove_tree(&root, &target, false, 100);
assert_eq!(outcome.removed, 4);
assert!(outcome.failures.is_empty());
assert!(!dir.path().join("app").exists(), "트리가 사라져야 한다");
}
#[test]
fn the_listing_truncates_but_the_count_does_not() {
let (_dir, root) = tree(&["app/a.txt", "app/b.txt", "app/c.txt"]);
let target = root.resolve_existing("app").expect("resolve");
let outcome = remove_tree(&root, &target, true, 2);
assert_eq!(outcome.removed, 4, "app 자신 + 파일 3개");
assert_eq!(outcome.entries.len(), 2);
assert!(outcome.truncated);
}
fn try_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_file(target, link)
}
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
Err(std::io::Error::other(
"symlinks unsupported on this platform",
))
}
}
fn try_symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_dir(target, link)
}
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
Err(std::io::Error::other(
"symlinks unsupported on this platform",
))
}
}
#[test]
fn an_entry_that_fails_to_enumerate_lands_in_failures() {
let (_dir, root) = tree(&["app/a.txt"]);
let parent = root.resolve_existing("app").expect("resolve");
let mut out = TreeOutcome::default();
visit_entry(
&root,
&parent,
Err(std::io::Error::other("enumeration failed")),
true,
100,
&mut out,
);
assert_eq!(out.failures, vec!["app/<unreadable entry>".to_string()]);
assert_eq!(out.removed, 0);
assert_eq!(out.bytes, 0);
assert!(out.entries.is_empty());
}
#[test]
fn an_unreadable_entry_at_the_root_is_not_named_with_a_leading_slash() {
let (_dir, root) = tree(&["app/a.txt"]);
let jail = root.jail_path().expect("이 픽스처는 jailed root를 만든다");
let mut out = TreeOutcome::default();
visit_entry(
&root,
jail,
Err(std::io::Error::other("enumeration failed")),
true,
100,
&mut out,
);
assert_eq!(out.failures, vec!["<unreadable entry>".to_string()]);
}
fn require_symlink(created: std::io::Result<()>, test_name: &str) {
if let Err(e) = created {
panic!("{test_name}: 심링크 생성 실패: {e} — 권한 문제라면 권한을 부여할 것(Windows: 개발자 모드 또는 SeCreateSymbolicLinkPrivilege). 테스트를 침묵시키는 것은 해법이 아니다.");
}
}
#[test]
fn a_symlink_is_removed_without_touching_its_target() {
let (dir, root) = tree(&["app/a.txt"]);
let outside = tempfile::tempdir().expect("outside");
let target_file = outside.path().join("keep.txt");
std::fs::write(&target_file, b"keep").expect("write");
require_symlink(
try_symlink(&target_file, &dir.path().join("app/link")),
"a_symlink_is_removed_without_touching_its_target",
);
let target = root.resolve_existing("app").expect("resolve");
let outcome = remove_tree(&root, &target, false, 100);
assert!(outcome.failures.is_empty(), "{:?}", outcome.failures);
assert!(!dir.path().join("app").exists());
assert!(target_file.exists(), "링크의 대상은 남아 있어야 한다");
}
#[test]
fn a_directory_symlink_is_removed_without_descending_into_its_target() {
let (dir, root) = tree(&["app/a.txt"]);
let outside = tempfile::tempdir().expect("outside");
let keep_dir = outside.path().join("keep_dir");
std::fs::create_dir(&keep_dir).expect("mkdir keep_dir");
let precious = keep_dir.join("precious.txt");
std::fs::write(&precious, b"precious").expect("write");
require_symlink(
try_symlink_dir(&keep_dir, &dir.path().join("app/dlink")),
"a_directory_symlink_is_removed_without_descending_into_its_target",
);
let target = root.resolve_existing("app").expect("resolve");
let preview = remove_tree(&root, &target, true, 100);
assert_eq!(
preview.removed, 3,
"app + a.txt + dlink, keep_dir 내용물은 세지 않는다"
);
assert!(preview.failures.is_empty(), "{:?}", preview.failures);
assert!(precious.exists(), "미리보기는 아무것도 지우지 않는다");
let outcome = remove_tree(&root, &target, false, 100);
assert_eq!(outcome.removed, 3);
assert!(outcome.failures.is_empty(), "{:?}", outcome.failures);
assert!(!dir.path().join("app").exists(), "트리가 사라져야 한다");
assert!(keep_dir.exists(), "링크의 대상 디렉터리는 남아 있어야 한다");
assert!(
precious.exists(),
"대상 디렉터리 안의 파일도 남아 있어야 한다"
);
}
}