use crate::util::human_size;
use std::collections::HashSet;
use std::fmt;
use std::path::{Path, PathBuf};
const MAX_SUFFIX: usize = 10_000;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum NodeKind {
File,
Dir,
Symlink,
}
#[derive(Clone, Debug)]
pub struct Node {
pub rel: PathBuf,
pub kind: NodeKind,
}
#[derive(Clone, Debug)]
pub struct Source {
pub path: PathBuf,
pub kind: NodeKind,
pub nodes: Vec<Node>,
pub items: usize,
pub bytes: u64,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Conflict {
Rename,
Overwrite,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Kind {
Copy,
Move,
Rename,
Create,
Trash,
}
impl Kind {
fn verb(self) -> &'static str {
match self {
Kind::Copy => "copy",
Kind::Move => "move",
Kind::Rename => "rename",
Kind::Create => "create",
Kind::Trash => "trash",
}
}
}
pub enum Op {
Copy {
sources: Vec<Source>,
dest: PathBuf,
},
Move {
sources: Vec<Source>,
dest: PathBuf,
},
Rename {
source: Source,
new_name: String,
},
Create {
parent: PathBuf,
name: String,
},
Trash {
sources: Vec<Source>,
},
}
#[derive(Clone, Debug)]
pub struct Step {
pub src: PathBuf,
pub dest: PathBuf,
pub kind: NodeKind,
pub nodes: Vec<Node>,
pub items: usize,
pub bytes: u64,
pub renamed: bool,
pub overwrite: bool,
}
#[derive(Clone, Debug)]
pub struct Plan {
pub kind: Kind,
pub dest: PathBuf,
pub steps: Vec<Step>,
pub missing: Vec<PathBuf>,
pub policy: Conflict,
}
impl Plan {
pub fn items(&self) -> usize {
self.steps.iter().map(|s| s.items).sum()
}
pub fn bytes(&self) -> u64 {
self.steps.iter().map(|s| s.bytes).sum()
}
pub fn renamed(&self) -> usize {
self.steps.iter().filter(|s| s.renamed).count()
}
pub fn overwrites(&self) -> usize {
self.steps.iter().filter(|s| s.overwrite).count()
}
pub fn summary(&self) -> String {
let items = self.items();
let noun = if items == 1 { "item" } else { "items" };
let mut out = format!("{} {items} {noun}", self.kind.verb());
let bytes = self.bytes();
if bytes > 0 {
out.push_str(&format!(", {}", human_size(bytes)));
}
let renamed = self.renamed();
if renamed > 0 {
out.push_str(&format!(", {renamed} renamed"));
}
let overwrites = self.overwrites();
if overwrites > 0 {
out.push_str(&format!(", {overwrites} overwritten"));
}
out
}
}
pub struct PlanCtx<'a> {
pub dest_listing: &'a [String],
pub cwd: &'a Path,
pub missing: &'a [PathBuf],
pub policy: Conflict,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Refusal {
NothingSelected,
IntoOwnDescendant { src: PathBuf, dest: PathBuf },
SameLocation { path: PathBuf },
AlreadyThere { path: PathBuf },
SameName { name: String },
BadName { name: String },
NameTaken { name: String },
FilesystemRoot,
AncestorOfCwd { path: PathBuf },
TooLarge { limit: usize },
TooDeep { path: PathBuf, limit: usize },
Io { path: PathBuf, msg: String },
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Refusal::NothingSelected => write!(f, "nothing selected"),
Refusal::IntoOwnDescendant { src, dest } => write!(
f,
"cannot put {} inside itself (destination {})",
src.display(),
dest.display()
),
Refusal::SameLocation { path } => {
write!(f, "{} is already the destination", path.display())
}
Refusal::AlreadyThere { path } => write!(
f,
"{} is already in the destination directory",
path.display()
),
Refusal::SameName { name } => write!(f, "\"{name}\" is already its name"),
Refusal::BadName { name } => write!(
f,
"\"{name}\" is not a usable name: it cannot be empty, \".\", \"..\", or contain a path separator"
),
Refusal::NameTaken { name } => write!(f, "\"{name}\" already exists here"),
Refusal::FilesystemRoot => write!(f, "refusing to operate on the filesystem root"),
Refusal::AncestorOfCwd { path } => write!(
f,
"{} holds the directory you are in, so it cannot be operated on from here",
path.display()
),
Refusal::TooLarge { limit } => {
write!(f, "refusing: this spans more than {limit} items")
}
Refusal::TooDeep { path, limit } => write!(
f,
"refusing: {} is nested deeper than {limit} levels",
path.display()
),
Refusal::Io { path, msg } => write!(f, "{}: {msg}", path.display()),
}
}
}
pub fn plan(op: Op, ctx: &PlanCtx) -> Result<Plan, Refusal> {
match op {
Op::Copy { sources, dest } => transfer(Kind::Copy, sources, dest, ctx),
Op::Move { sources, dest } => transfer(Kind::Move, sources, dest, ctx),
Op::Rename { source, new_name } => rename(source, new_name, ctx),
Op::Create { parent, name } => create(parent, name, ctx),
Op::Trash { sources } => trash(sources, ctx),
}
}
fn transfer(
kind: Kind,
sources: Vec<Source>,
dest: PathBuf,
ctx: &PlanCtx,
) -> Result<Plan, Refusal> {
if sources.is_empty() {
return Err(Refusal::NothingSelected);
}
for source in &sources {
guard(&source.path, ctx)?;
if source.path == dest {
return Err(Refusal::SameLocation {
path: source.path.clone(),
});
}
if dest.starts_with(&source.path) {
return Err(Refusal::IntoOwnDescendant {
src: source.path.clone(),
dest: dest.clone(),
});
}
if kind == Kind::Move && source.path.parent() == Some(dest.as_path()) {
return Err(Refusal::AlreadyThere {
path: source.path.clone(),
});
}
}
let mut names = Names::new(ctx.dest_listing);
let mut steps = Vec::with_capacity(sources.len());
for source in sources {
let name = leaf(&source.path)?;
let (final_name, renamed, overwrite) = names.take(&name, source.kind, ctx.policy)?;
steps.push(Step {
dest: dest.join(final_name),
src: source.path,
kind: source.kind,
nodes: source.nodes,
items: source.items,
bytes: source.bytes,
renamed,
overwrite,
});
}
Ok(Plan {
kind,
dest,
steps,
missing: ctx.missing.to_vec(),
policy: ctx.policy,
})
}
fn rename(source: Source, new_name: String, ctx: &PlanCtx) -> Result<Plan, Refusal> {
guard(&source.path, ctx)?;
let Some(parent) = source.path.parent() else {
return Err(Refusal::FilesystemRoot);
};
validate(&new_name)?;
if leaf(&source.path)? == new_name {
return Err(Refusal::SameName { name: new_name });
}
if ctx.dest_listing.contains(&new_name) {
return Err(Refusal::NameTaken { name: new_name });
}
let dest_dir = parent.to_path_buf();
let step = Step {
dest: dest_dir.join(&new_name),
src: source.path,
kind: source.kind,
nodes: source.nodes,
items: source.items,
bytes: source.bytes,
renamed: false,
overwrite: false,
};
Ok(Plan {
kind: Kind::Rename,
dest: dest_dir,
steps: vec![step],
missing: ctx.missing.to_vec(),
policy: ctx.policy,
})
}
fn create(parent: PathBuf, name: String, ctx: &PlanCtx) -> Result<Plan, Refusal> {
let as_dir = name.ends_with('/');
let bare = if as_dir {
&name[..name.len() - 1]
} else {
name.as_str()
};
validate(bare)?;
if ctx.dest_listing.iter().any(|e| e == bare) {
return Err(Refusal::NameTaken {
name: bare.to_string(),
});
}
let step = Step {
src: PathBuf::new(),
dest: parent.join(bare),
kind: if as_dir {
NodeKind::Dir
} else {
NodeKind::File
},
nodes: Vec::new(),
items: 1,
bytes: 0,
renamed: false,
overwrite: false,
};
Ok(Plan {
kind: Kind::Create,
dest: parent,
steps: vec![step],
missing: ctx.missing.to_vec(),
policy: ctx.policy,
})
}
fn trash(sources: Vec<Source>, ctx: &PlanCtx) -> Result<Plan, Refusal> {
if sources.is_empty() {
return Err(Refusal::NothingSelected);
}
for source in &sources {
guard(&source.path, ctx)?;
}
let steps = sources
.into_iter()
.map(|source| Step {
src: source.path,
dest: PathBuf::new(),
kind: source.kind,
nodes: source.nodes,
items: source.items,
bytes: source.bytes,
renamed: false,
overwrite: false,
})
.collect();
Ok(Plan {
kind: Kind::Trash,
dest: PathBuf::new(),
steps,
missing: ctx.missing.to_vec(),
policy: ctx.policy,
})
}
fn guard(path: &Path, ctx: &PlanCtx) -> Result<(), Refusal> {
if path.parent().is_none() {
return Err(Refusal::FilesystemRoot);
}
if ctx.cwd.starts_with(path) {
return Err(Refusal::AncestorOfCwd {
path: path.to_path_buf(),
});
}
Ok(())
}
fn leaf(path: &Path) -> Result<String, Refusal> {
match path.file_name() {
Some(name) => Ok(name.to_string_lossy().into_owned()),
None => Err(Refusal::BadName {
name: path.to_string_lossy().into_owned(),
}),
}
}
fn validate(name: &str) -> Result<(), Refusal> {
if name.is_empty() || name == "." || name == ".." || name.chars().any(std::path::is_separator) {
return Err(Refusal::BadName {
name: name.to_string(),
});
}
Ok(())
}
struct Names {
existing: HashSet<String>,
allocated: HashSet<String>,
}
impl Names {
fn new(listing: &[String]) -> Self {
Self {
existing: listing.iter().cloned().collect(),
allocated: HashSet::new(),
}
}
fn take(
&mut self,
name: &str,
kind: NodeKind,
policy: Conflict,
) -> Result<(String, bool, bool), Refusal> {
let in_batch = self.allocated.contains(name);
let in_dest = self.existing.contains(name);
if !in_batch && !in_dest {
self.allocated.insert(name.to_string());
return Ok((name.to_string(), false, false));
}
if policy == Conflict::Overwrite && !in_batch {
self.allocated.insert(name.to_string());
return Ok((name.to_string(), false, true));
}
for n in 2..=MAX_SUFFIX {
let candidate = suffixed(name, n, kind);
if !self.existing.contains(&candidate) && !self.allocated.contains(&candidate) {
self.allocated.insert(candidate.clone());
return Ok((candidate, true, false));
}
}
Err(Refusal::NameTaken {
name: name.to_string(),
})
}
}
fn suffixed(name: &str, n: usize, kind: NodeKind) -> String {
if kind != NodeKind::Dir {
if let Some(dot) = name.rfind('.') {
if dot > 0 && dot + 1 < name.len() {
return format!("{} ({n}).{}", &name[..dot], &name[dot + 1..]);
}
}
}
format!("{name} ({n})")
}
#[cfg(test)]
mod tests {
use super::*;
fn names(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
fn file(path: &str, bytes: u64) -> Source {
Source {
path: PathBuf::from(path),
kind: NodeKind::File,
nodes: Vec::new(),
items: 1,
bytes,
}
}
fn dir(path: &str, nodes: &[(&str, NodeKind, u64)]) -> Source {
let bytes = nodes.iter().map(|(_, _, size)| *size).sum();
let nodes: Vec<Node> = nodes
.iter()
.map(|(rel, kind, _)| Node {
rel: PathBuf::from(rel),
kind: *kind,
})
.collect();
Source {
path: PathBuf::from(path),
kind: NodeKind::Dir,
items: 1 + nodes.len(),
bytes,
nodes,
}
}
fn ctx<'a>(listing: &'a [String], cwd: &'a str) -> PlanCtx<'a> {
PlanCtx {
dest_listing: listing,
cwd: Path::new(cwd),
missing: &[],
policy: Conflict::Rename,
}
}
fn dests(plan: &Plan) -> Vec<String> {
plan.steps
.iter()
.map(|s| s.dest.to_string_lossy().into_owned())
.collect()
}
fn copy(sources: Vec<Source>, dest: &str, ctx: &PlanCtx) -> Result<Plan, Refusal> {
plan(
Op::Copy {
sources,
dest: PathBuf::from(dest),
},
ctx,
)
}
#[test]
fn free_names_are_kept_verbatim() {
let listing = names(&["other.txt"]);
let p = copy(vec![file("/src/a.txt", 10)], "/dst", &ctx(&listing, "/dst"))
.expect("plan should resolve");
assert_eq!(dests(&p), vec!["/dst/a.txt"]);
assert!(!p.steps[0].renamed);
assert!(!p.steps[0].overwrite);
assert_eq!(p.renamed(), 0);
assert_eq!(p.kind, Kind::Copy);
assert_eq!(p.dest, PathBuf::from("/dst"));
}
#[test]
fn collision_suffix_goes_before_the_last_extension() {
let listing = names(&["a.txt", "b.tar.gz"]);
let p = copy(
vec![file("/src/a.txt", 1), file("/src/b.tar.gz", 2)],
"/dst",
&ctx(&listing, "/dst"),
)
.expect("plan should resolve");
assert_eq!(dests(&p), vec!["/dst/a (2).txt", "/dst/b.tar (2).gz"]);
assert!(p.steps.iter().all(|s| s.renamed));
assert_eq!(p.renamed(), 2);
}
#[test]
fn collision_counts_up_until_a_free_name() {
let listing = names(&["a.txt", "a (2).txt", "a (3).txt"]);
let p = copy(vec![file("/src/a.txt", 1)], "/dst", &ctx(&listing, "/dst"))
.expect("plan should resolve");
assert_eq!(dests(&p), vec!["/dst/a (4).txt"]);
}
#[test]
fn dotfiles_have_no_extension_to_split() {
let listing = names(&[".gitignore"]);
let p = copy(
vec![file("/src/.gitignore", 1)],
"/dst",
&ctx(&listing, "/dst"),
)
.expect("plan should resolve");
assert_eq!(dests(&p), vec!["/dst/.gitignore (2)"]);
}
#[test]
fn directories_keep_their_dots() {
let listing = names(&["docs", "v1.2"]);
let p = copy(
vec![dir("/src/docs", &[]), dir("/src/v1.2", &[])],
"/dst",
&ctx(&listing, "/dst"),
)
.expect("plan should resolve");
assert_eq!(dests(&p), vec!["/dst/docs (2)", "/dst/v1.2 (2)"]);
}
#[test]
fn a_trailing_dot_is_not_an_extension() {
let listing = names(&["odd."]);
let p = copy(vec![file("/src/odd.", 1)], "/dst", &ctx(&listing, "/dst"))
.expect("plan should resolve");
assert_eq!(dests(&p), vec!["/dst/odd. (2)"]);
}
#[test]
fn two_sources_sharing_a_name_never_collide_with_each_other() {
let listing = names(&[]);
let p = copy(
vec![file("/one/a.txt", 1), file("/two/a.txt", 2)],
"/dst",
&ctx(&listing, "/dst"),
)
.expect("plan should resolve");
assert_eq!(dests(&p), vec!["/dst/a.txt", "/dst/a (2).txt"]);
assert!(!p.steps[0].renamed);
assert!(p.steps[1].renamed);
}
#[test]
fn overwrite_policy_keeps_the_name_and_flags_the_step() {
let listing = names(&["a.txt"]);
let world = PlanCtx {
dest_listing: &listing,
cwd: Path::new("/dst"),
missing: &[],
policy: Conflict::Overwrite,
};
let p = copy(vec![file("/src/a.txt", 1)], "/dst", &world).expect("plan should resolve");
assert_eq!(dests(&p), vec!["/dst/a.txt"]);
assert!(p.steps[0].overwrite);
assert!(!p.steps[0].renamed);
assert_eq!(p.overwrites(), 1);
assert_eq!(p.policy, Conflict::Overwrite);
}
#[test]
fn overwrite_policy_still_separates_two_sources_in_one_batch() {
let listing = names(&["a.txt"]);
let world = PlanCtx {
dest_listing: &listing,
cwd: Path::new("/dst"),
missing: &[],
policy: Conflict::Overwrite,
};
let p = copy(
vec![file("/one/a.txt", 1), file("/two/a.txt", 2)],
"/dst",
&world,
)
.expect("plan should resolve");
assert_eq!(dests(&p), vec!["/dst/a.txt", "/dst/a (2).txt"]);
assert!(p.steps[0].overwrite);
assert!(p.steps[1].renamed);
assert!(!p.steps[1].overwrite);
}
#[test]
fn copying_into_the_sources_own_directory_duplicates_it() {
let listing = names(&["a.txt"]);
let p = copy(
vec![file("/dst/a.txt", 1)],
"/dst",
&ctx(&listing, "/elsewhere"),
)
.expect("a duplicate is a real intent");
assert_eq!(dests(&p), vec!["/dst/a (2).txt"]);
}
#[test]
fn moving_into_the_sources_own_directory_is_refused() {
let listing = names(&["a.txt"]);
let err = plan(
Op::Move {
sources: vec![file("/dst/a.txt", 1)],
dest: PathBuf::from("/dst"),
},
&ctx(&listing, "/elsewhere"),
)
.expect_err("moving a file onto itself is nothing");
assert_eq!(
err,
Refusal::AlreadyThere {
path: PathBuf::from("/dst/a.txt")
}
);
}
#[test]
fn an_empty_selection_is_refused() {
let listing = names(&[]);
assert_eq!(
copy(vec![], "/dst", &ctx(&listing, "/dst")).expect_err("nothing to do"),
Refusal::NothingSelected
);
assert_eq!(
plan(Op::Trash { sources: vec![] }, &ctx(&listing, "/dst")).expect_err("nothing to do"),
Refusal::NothingSelected
);
}
#[test]
fn a_destination_inside_the_source_is_refused() {
let listing = names(&[]);
let err = copy(
vec![dir("/src/tree", &[("leaf", NodeKind::File, 1)])],
"/src/tree/deep",
&ctx(&listing, "/elsewhere"),
)
.expect_err("a tree cannot contain itself");
assert_eq!(
err,
Refusal::IntoOwnDescendant {
src: PathBuf::from("/src/tree"),
dest: PathBuf::from("/src/tree/deep"),
}
);
}
#[test]
fn a_destination_equal_to_the_source_is_refused() {
let listing = names(&[]);
let err = copy(
vec![dir("/src/tree", &[])],
"/src/tree",
&ctx(&listing, "/elsewhere"),
)
.expect_err("the source is not its own destination");
assert_eq!(
err,
Refusal::SameLocation {
path: PathBuf::from("/src/tree")
}
);
}
#[test]
fn the_filesystem_root_is_refused() {
let listing = names(&[]);
let err = copy(
vec![dir("/", &[])],
"/dst",
&PlanCtx {
dest_listing: &listing,
cwd: Path::new("/"),
missing: &[],
policy: Conflict::Rename,
},
)
.expect_err("/ is not a thing to copy");
assert_eq!(err, Refusal::FilesystemRoot);
}
#[test]
fn an_ancestor_of_the_current_directory_is_refused() {
let listing = names(&[]);
let err = copy(
vec![dir("/home/me/project", &[])],
"/dst",
&ctx(&listing, "/home/me/project/src/deep"),
)
.expect_err("that folder holds the user's own footing");
assert_eq!(
err,
Refusal::AncestorOfCwd {
path: PathBuf::from("/home/me/project")
}
);
}
#[test]
fn the_current_directory_itself_counts_as_an_ancestor() {
let listing = names(&[]);
let err = plan(
Op::Trash {
sources: vec![dir("/home/me/project", &[])],
},
&ctx(&listing, "/home/me/project"),
)
.expect_err("trashing the folder you stand in is the same surprise");
assert_eq!(
err,
Refusal::AncestorOfCwd {
path: PathBuf::from("/home/me/project")
}
);
}
#[test]
fn rename_maps_the_source_to_a_sibling_path() {
let listing = names(&["old.txt", "other.txt"]);
let p = plan(
Op::Rename {
source: file("/dst/old.txt", 42),
new_name: "new.txt".to_string(),
},
&ctx(&listing, "/dst"),
)
.expect("plan should resolve");
assert_eq!(p.kind, Kind::Rename);
assert_eq!(dests(&p), vec!["/dst/new.txt"]);
assert_eq!(p.steps[0].src, PathBuf::from("/dst/old.txt"));
assert_eq!(p.dest, PathBuf::from("/dst"));
assert!(!p.steps[0].renamed);
assert_eq!(p.bytes(), 42);
}
#[test]
fn rename_onto_an_existing_name_is_refused_not_suffixed() {
let listing = names(&["old.txt", "taken.txt"]);
let err = plan(
Op::Rename {
source: file("/dst/old.txt", 1),
new_name: "taken.txt".to_string(),
},
&ctx(&listing, "/dst"),
)
.expect_err("a rename says what the name must be");
assert_eq!(
err,
Refusal::NameTaken {
name: "taken.txt".to_string()
}
);
}
#[test]
fn rename_to_the_same_name_is_refused() {
let listing = names(&["old.txt"]);
let err = plan(
Op::Rename {
source: file("/dst/old.txt", 1),
new_name: "old.txt".to_string(),
},
&ctx(&listing, "/dst"),
)
.expect_err("changing nothing is not an operation");
assert_eq!(
err,
Refusal::SameName {
name: "old.txt".to_string()
}
);
}
#[test]
fn bad_names_are_refused_for_rename_and_create() {
let listing = names(&[]);
let world = ctx(&listing, "/dst");
for bad in ["", ".", "..", "a/b", "/abs"] {
let err = plan(
Op::Rename {
source: file("/dst/old.txt", 1),
new_name: bad.to_string(),
},
&world,
)
.expect_err("not a usable name");
assert_eq!(
err,
Refusal::BadName {
name: bad.to_string()
},
"rename {bad:?}"
);
let err = plan(
Op::Create {
parent: PathBuf::from("/dst"),
name: bad.to_string(),
},
&world,
)
.expect_err("not a usable name");
assert_eq!(
err,
Refusal::BadName {
name: bad.to_string()
},
"create {bad:?}"
);
}
assert_eq!(
plan(
Op::Create {
parent: PathBuf::from("/dst"),
name: "/".to_string(),
},
&world,
)
.expect_err("an unnamed directory"),
Refusal::BadName {
name: String::new()
}
);
}
#[test]
#[cfg(windows)]
fn a_backslash_is_a_separator_on_windows() {
let listing = names(&[]);
assert_eq!(
plan(
Op::Create {
parent: PathBuf::from("/dst"),
name: "a\\b".to_string(),
},
&ctx(&listing, "/dst"),
)
.expect_err("a name is not a path"),
Refusal::BadName {
name: "a\\b".to_string()
}
);
}
#[test]
fn create_makes_a_file_or_a_directory_by_its_trailing_slash() {
let listing = names(&[]);
let world = ctx(&listing, "/dst");
let f = plan(
Op::Create {
parent: PathBuf::from("/dst"),
name: "notes.md".to_string(),
},
&world,
)
.expect("plan should resolve");
assert_eq!(f.kind, Kind::Create);
assert_eq!(f.steps[0].kind, NodeKind::File);
assert_eq!(dests(&f), vec!["/dst/notes.md"]);
assert_eq!(f.steps[0].src, PathBuf::new());
assert_eq!(f.items(), 1);
assert_eq!(f.bytes(), 0);
let d = plan(
Op::Create {
parent: PathBuf::from("/dst"),
name: "sub/".to_string(),
},
&world,
)
.expect("plan should resolve");
assert_eq!(d.steps[0].kind, NodeKind::Dir);
assert_eq!(dests(&d), vec!["/dst/sub"]);
}
#[test]
fn create_onto_an_existing_name_is_refused() {
let listing = names(&["notes.md", "sub"]);
let world = ctx(&listing, "/dst");
assert_eq!(
plan(
Op::Create {
parent: PathBuf::from("/dst"),
name: "notes.md".to_string(),
},
&world,
)
.expect_err("create never suffixes"),
Refusal::NameTaken {
name: "notes.md".to_string()
}
);
assert_eq!(
plan(
Op::Create {
parent: PathBuf::from("/dst"),
name: "sub/".to_string(),
},
&world,
)
.expect_err("create never suffixes"),
Refusal::NameTaken {
name: "sub".to_string()
}
);
}
#[test]
fn trash_plans_one_step_per_source_with_no_destination() {
let listing = names(&[]);
let p = plan(
Op::Trash {
sources: vec![
file("/dst/a.txt", 10),
dir("/dst/tree", &[("leaf", NodeKind::File, 5)]),
],
},
&ctx(&listing, "/dst"),
)
.expect("plan should resolve");
assert_eq!(p.kind, Kind::Trash);
assert_eq!(p.dest, PathBuf::new());
assert_eq!(p.steps.len(), 2);
assert!(p.steps.iter().all(|s| s.dest == PathBuf::new()));
assert_eq!(p.items(), 3);
assert_eq!(p.bytes(), 15);
}
#[test]
fn totals_sum_items_and_bytes_across_steps() {
let listing = names(&[]);
let p = copy(
vec![
file("/src/a.bin", 1_000),
dir(
"/src/tree",
&[
("one", NodeKind::File, 200),
("sub", NodeKind::Dir, 0),
("sub/two", NodeKind::File, 24),
],
),
],
"/dst",
&ctx(&listing, "/dst"),
)
.expect("plan should resolve");
assert_eq!(p.items(), 1 + 4);
assert_eq!(p.bytes(), 1_224);
assert_eq!(p.steps[1].nodes.len(), 3);
assert_eq!(p.steps[1].nodes[2].rel, PathBuf::from("sub/two"));
}
#[test]
fn summary_reads_as_one_sentence() {
let listing = names(&["a.txt"]);
let p = copy(
vec![file("/one/a.txt", 3 * 1024), file("/two/b.txt", 1024)],
"/dst",
&ctx(&listing, "/dst"),
)
.expect("plan should resolve");
assert_eq!(p.summary(), "copy 2 items, 4.0K, 1 renamed");
let single = plan(
Op::Create {
parent: PathBuf::from("/dst"),
name: "new.md".to_string(),
},
&ctx(&listing, "/dst"),
)
.expect("plan should resolve");
assert_eq!(single.summary(), "create 1 item");
}
#[test]
fn vanished_marks_ride_along_into_the_plan() {
let listing = names(&[]);
let gone = vec![PathBuf::from("/src/gone.txt")];
let world = PlanCtx {
dest_listing: &listing,
cwd: Path::new("/dst"),
missing: &gone,
policy: Conflict::Rename,
};
let p = copy(vec![file("/src/here.txt", 1)], "/dst", &world).expect("plan should resolve");
assert_eq!(p.missing, gone);
}
#[test]
fn every_refusal_renders_one_sentence() {
let cases = [
Refusal::NothingSelected,
Refusal::IntoOwnDescendant {
src: PathBuf::from("/a"),
dest: PathBuf::from("/a/b"),
},
Refusal::SameLocation {
path: PathBuf::from("/a"),
},
Refusal::AlreadyThere {
path: PathBuf::from("/a/b.txt"),
},
Refusal::SameName {
name: "b.txt".to_string(),
},
Refusal::BadName {
name: "..".to_string(),
},
Refusal::NameTaken {
name: "b.txt".to_string(),
},
Refusal::FilesystemRoot,
Refusal::AncestorOfCwd {
path: PathBuf::from("/home"),
},
Refusal::TooLarge { limit: 50_000 },
Refusal::TooDeep {
path: PathBuf::from("/a/deep"),
limit: 64,
},
Refusal::Io {
path: PathBuf::from("/a/locked"),
msg: "permission denied".to_string(),
},
];
for case in cases {
let text = case.to_string();
assert!(!text.is_empty(), "{case:?} renders nothing");
assert!(!text.contains('\n'), "{case:?} is more than one line");
}
assert_eq!(
Refusal::TooLarge { limit: 50_000 }.to_string(),
"refusing: this spans more than 50000 items"
);
assert_eq!(
Refusal::Io {
path: PathBuf::from("/a/locked"),
msg: "permission denied".to_string(),
}
.to_string(),
"/a/locked: permission denied"
);
}
}