use crate::fs_model::{home_dir, human_size, trash_files_dir, trash_info_dir};
use std::env;
use std::ffi::OsStr;
use std::fs::{self, Metadata};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{self, Receiver, SyncSender};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
const OPERATION_EVENT_CAPACITY: usize = 64;
pub const CHILD_REAPER_LIMIT: usize = 32;
pub const OPERATION_SOURCE_LIMIT: usize = 20_000;
pub const OPERATION_ITEM_LIMIT: usize = 250_000;
pub const OPERATION_DEPTH_LIMIT: usize = 128;
static ACTIVE_CHILD_REAPERS: AtomicUsize = AtomicUsize::new(0);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClipboardMode {
Copy,
Cut,
}
#[derive(Clone, Debug)]
pub struct ClipboardPayload {
pub mode: ClipboardMode,
pub paths: Vec<PathBuf>,
}
impl ClipboardPayload {
pub fn label(&self) -> &'static str {
match self.mode {
ClipboardMode::Copy => "COPY BUFFER",
ClipboardMode::Cut => "CUT BUFFER",
}
}
}
#[derive(Clone, Debug)]
pub enum OperationRequest {
Copy {
sources: Vec<PathBuf>,
target_dir: PathBuf,
},
Move {
sources: Vec<PathBuf>,
target_dir: PathBuf,
},
Duplicate {
sources: Vec<PathBuf>,
},
Trash {
sources: Vec<PathBuf>,
},
Delete {
sources: Vec<PathBuf>,
},
Undo(UndoAction),
}
impl OperationRequest {
pub fn title(&self) -> &'static str {
match self {
Self::Copy { .. } => "COPY",
Self::Move { .. } => "MOVE",
Self::Duplicate { .. } => "DUPLICATE",
Self::Trash { .. } => "MOVE TO TRASH",
Self::Delete { .. } => "PERMANENT DELETE",
Self::Undo(_) => "UNDO",
}
}
}
#[derive(Clone, Debug)]
pub enum UndoAction {
RemoveCreated {
paths: Vec<PathBuf>,
label: String,
},
MoveBack {
moves: Vec<FileMove>,
label: String,
},
RestoreTrash {
records: Vec<TrashRecord>,
label: String,
},
RenameBack {
from: PathBuf,
to: PathBuf,
},
}
impl UndoAction {
pub fn label(&self) -> &str {
match self {
Self::RemoveCreated { label, .. }
| Self::MoveBack { label, .. }
| Self::RestoreTrash { label, .. } => label,
Self::RenameBack { .. } => "UNDO RENAME",
}
}
}
#[derive(Clone, Debug)]
pub struct FileMove {
pub from: PathBuf,
pub to: PathBuf,
}
#[derive(Clone, Debug)]
pub struct TrashRecord {
pub original_path: PathBuf,
pub trashed_path: PathBuf,
pub info_path: PathBuf,
}
#[derive(Clone, Debug)]
pub enum CompletedAction {
Copied(Vec<PathBuf>),
Moved(Vec<FileMove>),
Duplicated(Vec<PathBuf>),
Trashed(Vec<TrashRecord>),
Deleted(Vec<PathBuf>),
UndoApplied,
}
#[derive(Clone, Debug)]
pub struct OperationOutcome {
pub summary: String,
pub action: CompletedAction,
pub warning: Option<String>,
}
#[derive(Clone, Debug)]
pub struct OperationProgress {
pub done_items: usize,
pub total_items: usize,
pub done_bytes: u64,
pub total_bytes: u64,
pub current: String,
}
#[derive(Clone, Debug)]
pub enum OperationEvent {
Started {
title: String,
total_items: usize,
total_bytes: u64,
},
Progress(OperationProgress),
Finished(Result<OperationOutcome, String>),
}
pub fn start_operation(request: OperationRequest) -> Receiver<OperationEvent> {
let (tx, rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let worker_tx = tx.clone();
if let Err(error) = thread::Builder::new()
.name("guth-file-operation".to_string())
.spawn(move || {
let result = run_operation(request, &worker_tx);
let _ = worker_tx.send(OperationEvent::Finished(result));
})
{
let _ = tx.send(OperationEvent::Finished(Err(format!(
"Could not start operation worker: {error}"
))));
}
rx
}
pub fn undo_for_created_folder(path: PathBuf) -> UndoAction {
UndoAction::RemoveCreated {
paths: vec![path],
label: "UNDO NEW FOLDER".to_string(),
}
}
pub fn undo_for_created_file(path: PathBuf) -> UndoAction {
UndoAction::RemoveCreated {
paths: vec![path],
label: "UNDO NEW FILE".to_string(),
}
}
pub fn undo_for_rename(from: PathBuf, to: PathBuf) -> UndoAction {
UndoAction::RenameBack { from, to }
}
pub fn launch_default(path: &Path) -> Result<(), String> {
let mut xdg_open = Command::new("xdg-open");
xdg_open.arg(path);
spawn_managed_child(xdg_open, &format!("default opener {}", path.display())).or_else(
|first_error| {
let mut gio = Command::new("gio");
gio.arg("open").arg(path);
spawn_managed_child(gio, &format!("gio open {}", path.display())).map_err(
|second_error| {
format!(
"No default opener accepted {}: {first_error}; {second_error}",
path.display()
)
},
)
},
)
}
pub fn launch_terminal_at(path: &Path) -> Result<(), String> {
let cwd = if path.is_dir() {
path.to_path_buf()
} else {
path.parent()
.unwrap_or_else(|| Path::new("/"))
.to_path_buf()
};
let mut candidates = Vec::new();
if let Ok(terminal) = env::var("TERMINAL") {
candidates.push(terminal);
}
candidates.extend([
"kgx".to_string(),
"gnome-terminal".to_string(),
"x-terminal-emulator".to_string(),
"konsole".to_string(),
"xfce4-terminal".to_string(),
"alacritty".to_string(),
"kitty".to_string(),
"xterm".to_string(),
]);
for binary in candidates {
let mut command = Command::new(&binary);
command.current_dir(&cwd);
if spawn_managed_child(command, &format!("terminal {binary}")).is_ok() {
return Ok(());
}
}
Err(format!(
"No terminal emulator launched in {}",
cwd.display()
))
}
pub fn launch_new_window(path: &Path) -> Result<(), String> {
let current_exe =
env::current_exe().map_err(|error| format!("Cannot locate executable: {error}"))?;
let mut command = Command::new(current_exe);
command.arg(path);
spawn_managed_child(command, &format!("new window {}", path.display()))
.map_err(|error| format!("Cannot open new window: {error}"))
}
pub fn active_child_reapers() -> usize {
ACTIVE_CHILD_REAPERS.load(Ordering::Relaxed)
}
fn spawn_managed_child(mut command: Command, label: &str) -> Result<(), String> {
let permit = ChildReaperPermit::try_acquire()?;
let (child_tx, child_rx) = mpsc::sync_channel::<Child>(1);
thread::Builder::new()
.name("guth-child-reaper".to_string())
.spawn(move || {
let _permit = permit;
if let Ok(mut child) = child_rx.recv() {
let _ = child.wait();
}
})
.map_err(|error| format!("Could not start child reaper for {label}: {error}"))?;
let child = command
.spawn()
.map_err(|error| format!("Could not launch {label}: {error}"))?;
match child_tx.send(child) {
Ok(()) => Ok(()),
Err(error) => {
let mut child = error.0;
let _ = child.kill();
let _ = child.wait();
Err(format!("Child reaper stopped before receiving {label}"))
}
}
}
struct ChildReaperPermit;
impl ChildReaperPermit {
fn try_acquire() -> Result<Self, String> {
let mut current = ACTIVE_CHILD_REAPERS.load(Ordering::Relaxed);
loop {
if current >= CHILD_REAPER_LIMIT {
return Err(format!(
"Child process reaper limit reached: {current}/{CHILD_REAPER_LIMIT}"
));
}
match ACTIVE_CHILD_REAPERS.compare_exchange_weak(
current,
current + 1,
Ordering::AcqRel,
Ordering::Relaxed,
) {
Ok(_) => return Ok(Self),
Err(actual) => current = actual,
}
}
}
}
impl Drop for ChildReaperPermit {
fn drop(&mut self) {
ACTIVE_CHILD_REAPERS.fetch_sub(1, Ordering::AcqRel);
}
}
fn run_operation(
request: OperationRequest,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
match request {
OperationRequest::Copy {
sources,
target_dir,
} => run_copy(prepare_sources(sources)?, target_dir, tx),
OperationRequest::Move {
sources,
target_dir,
} => run_move(prepare_sources(sources)?, target_dir, tx),
OperationRequest::Duplicate { sources } => run_duplicate(prepare_sources(sources)?, tx),
OperationRequest::Trash { sources } => run_trash(prepare_sources(sources)?, tx),
OperationRequest::Delete { sources } => run_delete(prepare_sources(sources)?, tx),
OperationRequest::Undo(action) => run_undo(action, tx),
}
}
fn run_copy(
sources: Vec<PathBuf>,
target_dir: PathBuf,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
let target_dir = fs::canonicalize(&target_dir).map_err(|error| {
format!(
"Cannot resolve copy target {}: {error}",
target_dir.display()
)
})?;
let total = measure_sources(&sources)?;
send_started(tx, "COPY", total);
let mut tracker = ProgressTracker::new(tx, total);
let mut created = Vec::new();
for source in sources {
let name = file_name_string(&source)?;
let destination = unique_child_path(&target_dir, &name);
ensure_destination_is_not_inside_source(&source, &destination)?;
copy_path(&source, &destination, &mut tracker).map_err(|error| {
format!(
"Copy failed for {} -> {}: {error}",
source.display(),
destination.display()
)
})?;
created.push(destination);
}
Ok(OperationOutcome {
summary: format!("COPIED {}", plural_items(created.len())),
action: CompletedAction::Copied(created),
warning: None,
})
}
fn run_move(
sources: Vec<PathBuf>,
target_dir: PathBuf,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
let target_dir = fs::canonicalize(&target_dir).map_err(|error| {
format!(
"Cannot resolve move target {}: {error}",
target_dir.display()
)
})?;
let mut movable_sources = Vec::new();
for source in sources {
let parent = source
.parent()
.ok_or_else(|| format!("{} has no source parent", source.display()))?;
let parent = fs::canonicalize(parent).map_err(|error| {
format!("Cannot resolve source parent {}: {error}", parent.display())
})?;
if parent != target_dir {
movable_sources.push(source);
}
}
if movable_sources.is_empty() {
return Err("All selected items are already in the destination folder".to_string());
}
let sources = movable_sources;
let total = measure_sources(&sources)?;
send_started(tx, "MOVE", total);
let mut tracker = ProgressTracker::new(tx, total);
let mut moves = Vec::new();
for source in sources {
let result = (|| {
let name = file_name_string(&source)?;
let destination = unique_child_path(&target_dir, &name);
ensure_destination_is_not_inside_source(&source, &destination)?;
move_path(&source, &destination, &mut tracker).map_err(|error| {
format!(
"Move failed for {} -> {}: {error}",
source.display(),
destination.display()
)
})?;
Ok::<_, String>(FileMove {
from: source,
to: destination,
})
})();
match result {
Ok(file_move) => moves.push(file_move),
Err(error) if moves.is_empty() => return Err(error),
Err(error) => {
return Ok(OperationOutcome {
summary: format!("MOVED {} BEFORE FAILURE", plural_items(moves.len())),
action: CompletedAction::Moved(moves),
warning: Some(error),
});
}
}
}
Ok(OperationOutcome {
summary: format!("MOVED {}", plural_items(moves.len())),
action: CompletedAction::Moved(moves),
warning: None,
})
}
fn run_duplicate(
sources: Vec<PathBuf>,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
let total = measure_sources(&sources)?;
send_started(tx, "DUPLICATE", total);
let mut tracker = ProgressTracker::new(tx, total);
let mut created = Vec::new();
for source in sources {
let parent = source
.parent()
.ok_or_else(|| format!("Cannot duplicate {} without a parent", source.display()))?;
let name = file_name_string(&source)?;
let destination = unique_copy_path(parent, &name);
copy_path(&source, &destination, &mut tracker).map_err(|error| {
format!(
"Duplicate failed for {} -> {}: {error}",
source.display(),
destination.display()
)
})?;
created.push(destination);
}
Ok(OperationOutcome {
summary: format!("DUPLICATED {}", plural_items(created.len())),
action: CompletedAction::Duplicated(created),
warning: None,
})
}
fn run_trash(
sources: Vec<PathBuf>,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
let total = measure_sources(&sources)?;
send_started(tx, "MOVE TO TRASH", total);
let mut tracker = ProgressTracker::new(tx, total);
let files_dir = trash_files_dir();
let info_dir = trash_info_dir();
fs::create_dir_all(&files_dir)
.map_err(|error| format!("Cannot create trash files dir: {error}"))?;
fs::create_dir_all(&info_dir)
.map_err(|error| format!("Cannot create trash info dir: {error}"))?;
let files_dir = fs::canonicalize(&files_dir)
.map_err(|error| format!("Cannot resolve trash files dir: {error}"))?;
let mut records = Vec::new();
for source in sources {
let result = (|| {
let canonical_source = fs::canonicalize(&source).map_err(|error| {
format!("Cannot resolve trash source {}: {error}", source.display())
})?;
if canonical_source.starts_with(&files_dir) {
return Err(format!("{} is already in trash", source.display()));
}
let name = file_name_string(&source)?;
let trashed_path = unique_child_path(&files_dir, &name);
ensure_destination_is_not_inside_source(&source, &trashed_path)?;
let info_name = format!("{}.trashinfo", file_name_string(&trashed_path)?);
let info_path = info_dir.join(info_name);
write_trash_info(&info_path, &source)?;
move_path(&source, &trashed_path, &mut tracker).map_err(|error| {
let _ = fs::remove_file(&info_path);
format!("Trash failed for {}: {error}", source.display())
})?;
Ok::<_, String>(TrashRecord {
original_path: source,
trashed_path,
info_path,
})
})();
match result {
Ok(record) => records.push(record),
Err(error) if records.is_empty() => return Err(error),
Err(error) => {
return Ok(OperationOutcome {
summary: format!("TRASHED {} BEFORE FAILURE", plural_items(records.len())),
action: CompletedAction::Trashed(records),
warning: Some(error),
});
}
}
}
Ok(OperationOutcome {
summary: format!("TRASHED {}", plural_items(records.len())),
action: CompletedAction::Trashed(records),
warning: None,
})
}
fn run_delete(
sources: Vec<PathBuf>,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
let total = measure_sources(&sources)?;
send_started(tx, "PERMANENT DELETE", total);
let mut tracker = ProgressTracker::new(tx, total);
let mut deleted = Vec::new();
for source in sources {
remove_path_with_progress(&source, &mut tracker)
.map_err(|error| format!("Delete failed for {}: {error}", source.display()))?;
deleted.push(source);
}
Ok(OperationOutcome {
summary: format!("PERMANENTLY DELETED {}", plural_items(deleted.len())),
action: CompletedAction::Deleted(deleted),
warning: None,
})
}
fn run_undo(
action: UndoAction,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
match action {
UndoAction::RemoveCreated { paths, .. } => {
let total = measure_sources(&paths)?;
send_started(tx, "UNDO REMOVE CREATED", total);
let mut tracker = ProgressTracker::new(tx, total);
for path in paths {
remove_path_with_progress(&path, &mut tracker).map_err(|error| {
format!("Undo remove failed for {}: {error}", path.display())
})?;
}
}
UndoAction::MoveBack { moves, .. } => {
let sources: Vec<PathBuf> =
moves.iter().map(|file_move| file_move.to.clone()).collect();
let total = measure_sources(&sources)?;
send_started(tx, "UNDO MOVE", total);
let mut tracker = ProgressTracker::new(tx, total);
for file_move in moves {
let destination = if path_entry_exists(&file_move.from) {
let parent = file_move.from.parent().ok_or_else(|| {
format!("{} has no restore parent", file_move.from.display())
})?;
let name = file_name_string(&file_move.from)?;
unique_child_path(parent, &name)
} else {
file_move.from.clone()
};
move_path(&file_move.to, &destination, &mut tracker).map_err(|error| {
format!(
"Undo move failed for {} -> {}: {error}",
file_move.to.display(),
destination.display()
)
})?;
}
}
UndoAction::RestoreTrash { records, .. } => {
let sources: Vec<PathBuf> = records
.iter()
.map(|record| record.trashed_path.clone())
.collect();
let total = measure_sources(&sources)?;
send_started(tx, "UNDO TRASH", total);
let mut tracker = ProgressTracker::new(tx, total);
for record in records {
let destination = if path_entry_exists(&record.original_path) {
let parent = record
.original_path
.parent()
.unwrap_or_else(|| Path::new("/"));
let name = file_name_string(&record.original_path)?;
unique_child_path(parent, &name)
} else {
record.original_path.clone()
};
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Cannot recreate restore parent {}: {error}",
parent.display()
)
})?;
}
move_path(&record.trashed_path, &destination, &mut tracker).map_err(|error| {
format!(
"Undo trash failed for {} -> {}: {error}",
record.trashed_path.display(),
destination.display()
)
})?;
let _ = fs::remove_file(record.info_path);
}
}
UndoAction::RenameBack { from, to } => {
let total = measure_sources(std::slice::from_ref(&to))?;
send_started(tx, "UNDO RENAME", total);
let mut tracker = ProgressTracker::new(tx, total);
let destination = if path_entry_exists(&from) {
let parent = from
.parent()
.ok_or_else(|| format!("{} has no restore parent", from.display()))?;
let name = file_name_string(&from)?;
unique_child_path(parent, &name)
} else {
from
};
move_path(&to, &destination, &mut tracker).map_err(|error| {
format!(
"Undo rename failed for {} -> {}: {error}",
to.display(),
destination.display()
)
})?;
}
}
Ok(OperationOutcome {
summary: "UNDO APPLIED".to_string(),
action: CompletedAction::UndoApplied,
warning: None,
})
}
#[derive(Clone, Copy, Debug, Default)]
struct SourceMeasure {
items: usize,
bytes: u64,
}
fn prepare_sources(sources: Vec<PathBuf>) -> Result<Vec<PathBuf>, String> {
validate_source_count(&sources)?;
if sources.is_empty() {
return Err("Operation has no sources".to_string());
}
let mut identified = sources
.into_iter()
.map(|source| source_identity(&source).map(|identity| (source, identity)))
.collect::<Result<Vec<_>, _>>()?;
identified.sort_by_key(|(_, identity)| identity.components().count());
let mut prepared = Vec::new();
let mut identities = Vec::<PathBuf>::new();
for (source, identity) in identified {
if identities
.iter()
.any(|parent| identity == *parent || identity.starts_with(parent))
{
continue;
}
identities.push(identity);
prepared.push(source);
}
Ok(prepared)
}
fn source_identity(source: &Path) -> Result<PathBuf, String> {
let metadata = fs::symlink_metadata(source)
.map_err(|error| format!("Cannot inspect source {}: {error}", source.display()))?;
if metadata.file_type().is_symlink() {
let parent = source
.parent()
.ok_or_else(|| format!("{} has no parent", source.display()))?;
let parent = fs::canonicalize(parent).map_err(|error| {
format!("Cannot resolve source parent {}: {error}", parent.display())
})?;
let name = source
.file_name()
.ok_or_else(|| format!("{} has no file name", source.display()))?;
Ok(parent.join(name))
} else {
fs::canonicalize(source)
.map_err(|error| format!("Cannot resolve source {}: {error}", source.display()))
}
}
fn measure_sources(sources: &[PathBuf]) -> Result<SourceMeasure, String> {
validate_source_count(sources)?;
let mut total = SourceMeasure::default();
for source in sources {
measure_path(source, &mut total, 0)?;
}
if total.items == 0 {
total.items = sources.len().max(1);
}
Ok(total)
}
fn validate_source_count(sources: &[PathBuf]) -> Result<(), String> {
if sources.len() > OPERATION_SOURCE_LIMIT {
return Err(format!(
"Operation source limit reached: {} selected, limit {}",
sources.len(),
OPERATION_SOURCE_LIMIT
));
}
Ok(())
}
fn measure_path(path: &Path, total: &mut SourceMeasure, depth: usize) -> Result<(), String> {
if depth > OPERATION_DEPTH_LIMIT {
return Err(format!(
"Operation depth limit reached at {} (limit {})",
path.display(),
OPERATION_DEPTH_LIMIT
));
}
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("Cannot inspect {}: {error}", path.display()))?;
if total.items >= OPERATION_ITEM_LIMIT {
return Err(format!(
"Operation item limit reached at {} (limit {})",
path.display(),
OPERATION_ITEM_LIMIT
));
}
total.items = total.items.saturating_add(1);
total.bytes = total.bytes.saturating_add(metadata.len());
if metadata.file_type().is_dir() {
let read_dir = fs::read_dir(path)
.map_err(|error| format!("Cannot read directory {}: {error}", path.display()))?;
for child in read_dir {
let child = child
.map_err(|error| format!("Cannot read entry in {}: {error}", path.display()))?;
measure_path(&child.path(), total, depth + 1)?;
}
}
Ok(())
}
struct ProgressTracker<'a> {
tx: &'a SyncSender<OperationEvent>,
done_items: usize,
processed_items: usize,
done_bytes: u64,
total: SourceMeasure,
}
impl<'a> ProgressTracker<'a> {
fn new(tx: &'a SyncSender<OperationEvent>, total: SourceMeasure) -> Self {
Self {
tx,
done_items: 0,
processed_items: 0,
done_bytes: 0,
total,
}
}
fn bump_item(&mut self, bytes: u64) -> io::Result<()> {
if self.processed_items >= OPERATION_ITEM_LIMIT {
return Err(operation_limit_error(format!(
"operation item limit reached at {} items",
OPERATION_ITEM_LIMIT
)));
}
self.processed_items = self.processed_items.saturating_add(1);
self.done_items = self
.done_items
.saturating_add(1)
.min(self.total.items.max(1));
self.done_bytes = self.done_bytes.saturating_add(bytes).min(self.total.bytes);
Ok(())
}
fn progress(&self, current: String, bytes: u64) {
let _ = self.tx.send(OperationEvent::Progress(OperationProgress {
done_items: self.done_items,
total_items: self.total.items.max(1),
done_bytes: self.done_bytes.saturating_add(bytes).min(self.total.bytes),
total_bytes: self.total.bytes,
current,
}));
}
fn step(&mut self, path: &Path, bytes: u64) -> io::Result<()> {
self.progress(path.display().to_string(), bytes);
self.bump_item(bytes)
}
}
fn send_started(tx: &SyncSender<OperationEvent>, title: &str, total: SourceMeasure) {
let _ = tx.send(OperationEvent::Started {
title: title.to_string(),
total_items: total.items.max(1),
total_bytes: total.bytes,
});
}
fn copy_path(
source: &Path,
destination: &Path,
tracker: &mut ProgressTracker<'_>,
) -> io::Result<()> {
copy_path_at_depth(source, destination, tracker, 0)
}
fn copy_path_at_depth(
source: &Path,
destination: &Path,
tracker: &mut ProgressTracker<'_>,
depth: usize,
) -> io::Result<()> {
if depth > OPERATION_DEPTH_LIMIT {
return Err(operation_limit_error(format!(
"operation depth limit reached at {} (limit {})",
source.display(),
OPERATION_DEPTH_LIMIT
)));
}
let metadata = fs::symlink_metadata(source)?;
if metadata.file_type().is_dir() {
fs::create_dir_all(destination)?;
tracker.step(destination, 0)?;
for child in fs::read_dir(source)? {
let child = child?;
copy_path_at_depth(
&child.path(),
&destination.join(child.file_name()),
tracker,
depth + 1,
)?;
}
return Ok(());
}
if metadata.file_type().is_symlink() {
copy_symlink(source, destination, &metadata)?;
tracker.step(destination, metadata.len())?;
return Ok(());
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
tracker.progress(destination.display().to_string(), metadata.len());
fs::copy(source, destination)?;
let _ = fs::set_permissions(destination, metadata.permissions());
tracker.bump_item(metadata.len())?;
Ok(())
}
fn operation_limit_error(message: String) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message)
}
#[cfg(unix)]
fn copy_symlink(source: &Path, destination: &Path, _metadata: &Metadata) -> io::Result<()> {
use std::os::unix::fs::symlink;
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
let target = fs::read_link(source)?;
symlink(target, destination)
}
#[cfg(not(unix))]
fn copy_symlink(source: &Path, destination: &Path, _metadata: &Metadata) -> io::Result<()> {
fs::copy(source, destination).map(|_| ())
}
fn move_path(
source: &Path,
destination: &Path,
tracker: &mut ProgressTracker<'_>,
) -> io::Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
tracker.progress(destination.display().to_string(), 0);
match fs::rename(source, destination) {
Ok(()) => {
tracker.bump_item(0)?;
Ok(())
}
Err(rename_error) => {
if let Err(copy_error) = copy_path(source, destination, tracker) {
let cleanup = remove_path(destination);
return Err(io::Error::new(
copy_error.kind(),
format!(
"rename failed: {rename_error}; fallback copy failed: {copy_error}; destination cleanup: {}",
cleanup
.map(|_| "complete".to_string())
.unwrap_or_else(|error| format!("failed: {error}"))
),
));
}
if let Err(remove_error) = remove_path(source) {
return Err(io::Error::new(
remove_error.kind(),
format!(
"rename failed: {rename_error}; source cleanup failed: {remove_error}; complete destination copy retained at {}",
destination.display()
),
));
}
Ok(())
}
}
}
fn remove_path(path: &Path) -> io::Result<()> {
remove_path_bounded(path, |_| Ok(()))
}
fn remove_path_with_progress(path: &Path, tracker: &mut ProgressTracker<'_>) -> io::Result<()> {
remove_path_bounded(path, |removed| {
tracker.progress(removed.display().to_string(), 0);
tracker.bump_item(0)
})
}
enum RemoveFrame {
Enter { path: PathBuf, depth: usize },
Exit { path: PathBuf },
}
fn remove_path_bounded<F>(path: &Path, mut on_removed: F) -> io::Result<()>
where
F: FnMut(&Path) -> io::Result<()>,
{
let mut scheduled = 1usize;
let mut stack = vec![RemoveFrame::Enter {
path: path.to_path_buf(),
depth: 0,
}];
while let Some(frame) = stack.pop() {
match frame {
RemoveFrame::Enter { path, depth } => {
if depth > OPERATION_DEPTH_LIMIT {
return Err(operation_limit_error(format!(
"operation depth limit reached at {} (limit {})",
path.display(),
OPERATION_DEPTH_LIMIT
)));
}
let metadata = fs::symlink_metadata(&path)?;
if metadata.file_type().is_dir() {
stack.push(RemoveFrame::Exit { path: path.clone() });
for child in fs::read_dir(&path)? {
let child = child?;
if scheduled >= OPERATION_ITEM_LIMIT {
return Err(operation_limit_error(format!(
"operation item limit reached at {} items",
OPERATION_ITEM_LIMIT
)));
}
scheduled = scheduled.saturating_add(1);
stack.push(RemoveFrame::Enter {
path: child.path(),
depth: depth + 1,
});
}
} else {
fs::remove_file(&path)?;
on_removed(&path)?;
}
}
RemoveFrame::Exit { path } => {
fs::remove_dir(&path)?;
on_removed(&path)?;
}
}
}
Ok(())
}
fn write_trash_info(info_path: &Path, original_path: &Path) -> Result<(), String> {
if let Some(parent) = info_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Cannot create trash info parent: {error}"))?;
}
let mut file = fs::File::create(info_path).map_err(|error| {
format!(
"Cannot write trash metadata {}: {error}",
info_path.display()
)
})?;
writeln!(file, "[Trash Info]").map_err(|error| error.to_string())?;
writeln!(file, "Path={}", percent_encode_path(original_path))
.map_err(|error| error.to_string())?;
writeln!(file, "DeletionDate={}", trash_timestamp()).map_err(|error| error.to_string())?;
Ok(())
}
fn file_name_string(path: &Path) -> Result<String, String> {
path.file_name()
.and_then(OsStr::to_str)
.map(str::to_string)
.ok_or_else(|| format!("{} has no valid file name", path.display()))
}
fn ensure_destination_is_not_inside_source(
source: &Path,
destination: &Path,
) -> Result<(), String> {
let metadata = fs::symlink_metadata(source)
.map_err(|error| format!("Cannot inspect source {}: {error}", source.display()))?;
if metadata.file_type().is_symlink() {
return Ok(());
}
let source = fs::canonicalize(source)
.map_err(|error| format!("Cannot resolve source {}: {error}", source.display()))?;
let destination_parent = destination
.parent()
.ok_or_else(|| format!("{} has no destination parent", destination.display()))?;
let destination_parent = fs::canonicalize(destination_parent).map_err(|error| {
format!(
"Cannot resolve destination parent {}: {error}",
destination_parent.display()
)
})?;
let destination = destination_parent.join(
destination
.file_name()
.ok_or_else(|| format!("{} has no destination name", destination.display()))?,
);
if destination.starts_with(&source) {
return Err(format!(
"Refusing to place {} inside selected source {}",
destination.display(),
source.display()
));
}
Ok(())
}
fn unique_child_path(parent: &Path, name: &str) -> PathBuf {
let direct = parent.join(name);
if !path_entry_exists(&direct) {
return direct;
}
unique_copy_path(parent, name)
}
fn unique_copy_path(parent: &Path, name: &str) -> PathBuf {
let (stem, extension) = split_extension(name);
for index in 1..10_000 {
let candidate_name = if index == 1 {
match extension {
Some(extension) => format!("{stem} copy.{extension}"),
None => format!("{stem} copy"),
}
} else {
match extension {
Some(extension) => format!("{stem} copy {index}.{extension}"),
None => format!("{stem} copy {index}"),
}
};
let candidate = parent.join(candidate_name);
if !path_entry_exists(&candidate) {
return candidate;
}
}
unique_suffixed_copy_path(parent, name, process_suffix())
}
fn unique_suffixed_copy_path(parent: &Path, name: &str, mut suffix: u128) -> PathBuf {
let mut index = 1_u64;
loop {
let candidate_name = if index == 1 {
format!("{name} copy {suffix}")
} else {
format!("{name} copy {suffix} {index}")
};
let candidate = parent.join(candidate_name);
if !path_entry_exists(&candidate) {
return candidate;
}
if index == u64::MAX {
suffix = suffix.wrapping_add(1);
index = 1;
} else {
index += 1;
}
}
}
fn path_entry_exists(path: &Path) -> bool {
match fs::symlink_metadata(path) {
Ok(_) => true,
Err(error) => error.kind() != io::ErrorKind::NotFound,
}
}
fn split_extension(name: &str) -> (&str, Option<&str>) {
if let Some(index) = name.rfind('.') {
if index > 0 && index < name.len() - 1 {
return (&name[..index], Some(&name[index + 1..]));
}
}
(name, None)
}
fn process_suffix() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default()
}
fn plural_items(count: usize) -> String {
if count == 1 {
"1 ITEM".to_string()
} else {
format!("{count} ITEMS")
}
}
fn percent_encode_path(path: &Path) -> String {
let text = path.to_string_lossy();
let mut encoded = String::new();
for byte in text.as_bytes() {
let is_safe =
byte.is_ascii_alphanumeric() || matches!(*byte, b'/' | b'.' | b'-' | b'_' | b'~');
if is_safe {
encoded.push(*byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn trash_timestamp() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default();
let days = (seconds / 86_400) as i64;
let seconds_of_day = seconds % 86_400;
let (year, month, day) = civil_from_days(days);
let hour = seconds_of_day / 3_600;
let minute = (seconds_of_day % 3_600) / 60;
let second = seconds_of_day % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}")
}
fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) {
let z = days_since_epoch + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = mp + if mp < 10 { 3 } else { -9 };
let year = y + if month <= 2 { 1 } else { 0 };
(year, month as u32, day as u32)
}
pub fn operation_percent(done_items: usize, total_items: usize) -> f32 {
if total_items == 0 {
0.0
} else {
done_items as f32 / total_items as f32
}
}
pub fn operation_detail(progress: &OperationProgress) -> String {
let byte_detail = if progress.total_bytes > 0 {
format!(
" // {} / {}",
human_size(progress.done_bytes),
human_size(progress.total_bytes)
)
} else {
String::new()
};
format!(
"{} / {} ITEMS{byte_detail} // {}",
progress.done_items, progress.total_items, progress.current
)
}
pub fn path_is_inside_home(path: &Path) -> bool {
path.starts_with(home_dir())
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDir(PathBuf);
impl TestDir {
fn new(label: &str) -> Self {
let path = env::temp_dir().join(format!(
"guth-ops-{label}-{}-{}",
std::process::id(),
process_suffix()
));
fs::create_dir_all(&path).unwrap();
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn unique_copy_names_keep_extensions() {
let parent = env::temp_dir().join(format!("guth-test-{}", process_suffix()));
fs::create_dir_all(&parent).unwrap();
fs::write(parent.join("alpha.txt"), b"x").unwrap();
assert_eq!(
unique_child_path(&parent, "alpha.txt"),
parent.join("alpha copy.txt")
);
let _ = fs::remove_dir_all(parent);
}
#[test]
fn suffixed_copy_names_are_always_checked() {
let root = TestDir::new("suffixed-copy");
fs::write(root.path().join("item copy 42"), b"one").unwrap();
fs::write(root.path().join("item copy 42 2"), b"two").unwrap();
assert_eq!(
unique_suffixed_copy_path(root.path(), "item", 42),
root.path().join("item copy 42 3")
);
}
#[test]
fn trash_timestamp_has_iso_shape() {
let stamp = trash_timestamp();
assert_eq!(stamp.len(), 19);
assert_eq!(&stamp[10..11], "T");
}
#[test]
fn measure_sources_rejects_source_limit() {
let sources: Vec<PathBuf> = (0..=OPERATION_SOURCE_LIMIT)
.map(|index| PathBuf::from(format!("missing-{index}")))
.collect();
let error = measure_sources(&sources).unwrap_err();
assert!(error.contains("source limit"));
}
#[test]
fn prepare_sources_collapses_nested_selections() {
let root = TestDir::new("nested-sources");
let folder = root.path().join("folder");
let child = folder.join("child.txt");
let sibling = root.path().join("sibling.txt");
fs::create_dir_all(&folder).unwrap();
fs::write(&child, b"child").unwrap();
fs::write(&sibling, b"sibling").unwrap();
let prepared = prepare_sources(vec![child, sibling.clone(), folder.clone()]).unwrap();
assert_eq!(prepared, vec![sibling, folder]);
}
#[test]
fn partial_move_returns_completed_items_for_undo() {
let root = TestDir::new("partial-move");
let first = root.path().join("first.txt");
let ancestor = root.path().join("ancestor");
let target = ancestor.join("target");
fs::write(&first, b"first").unwrap();
fs::create_dir_all(&target).unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let outcome = run_move(vec![first.clone(), ancestor], target.clone(), &tx).unwrap();
assert!(outcome.warning.is_some());
let CompletedAction::Moved(moves) = outcome.action else {
panic!("expected partial move outcome");
};
assert_eq!(moves.len(), 1);
assert_eq!(moves[0].from, first);
assert!(target.join("first.txt").is_file());
}
#[test]
fn undo_move_does_not_overwrite_replacement_data() {
let root = TestDir::new("undo-move-conflict");
let original = root.path().join("item.txt");
let moved = root.path().join("moved.txt");
fs::write(&original, b"replacement").unwrap();
fs::write(&moved, b"moved-data").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
run_undo(
UndoAction::MoveBack {
moves: vec![FileMove {
from: original.clone(),
to: moved,
}],
label: "UNDO MOVE".to_string(),
},
&tx,
)
.unwrap();
assert_eq!(fs::read(&original).unwrap(), b"replacement");
assert_eq!(
fs::read(root.path().join("item copy.txt")).unwrap(),
b"moved-data"
);
}
#[cfg(unix)]
#[test]
fn dangling_symlinks_reserve_destination_names() {
use std::os::unix::fs::symlink;
let root = TestDir::new("dangling-destination");
let dangling = root.path().join("item.txt");
symlink(root.path().join("missing-target"), &dangling).unwrap();
assert!(path_entry_exists(&dangling));
assert_eq!(
unique_child_path(root.path(), "item.txt"),
root.path().join("item copy.txt")
);
}
#[cfg(unix)]
#[test]
fn undo_rename_preserves_dangling_replacement() {
use std::os::unix::fs::symlink;
let root = TestDir::new("undo-rename-dangling");
let original = root.path().join("item.txt");
let renamed = root.path().join("renamed.txt");
let missing_target = root.path().join("missing-target");
symlink(&missing_target, &original).unwrap();
fs::write(&renamed, b"renamed-data").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
run_undo(
UndoAction::RenameBack {
from: original.clone(),
to: renamed,
},
&tx,
)
.unwrap();
assert_eq!(fs::read_link(&original).unwrap(), missing_target);
assert_eq!(
fs::read(root.path().join("item copy.txt")).unwrap(),
b"renamed-data"
);
}
#[cfg(unix)]
#[test]
fn descendant_check_resolves_symlinked_destination_parents() {
use std::os::unix::fs::symlink;
let root = TestDir::new("symlink-destination");
let source = root.path().join("source");
let child = source.join("child");
let alias = root.path().join("alias");
fs::create_dir_all(&child).unwrap();
symlink(&child, &alias).unwrap();
let error =
ensure_destination_is_not_inside_source(&source, &alias.join("moved")).unwrap_err();
assert!(error.contains("inside selected source"));
}
#[test]
fn measure_sources_reports_missing_inputs() {
let root = TestDir::new("missing-source");
let error = measure_sources(&[root.path().join("missing")]).unwrap_err();
assert!(error.contains("Cannot inspect"));
}
#[test]
fn measure_sources_rejects_depth_limit() {
let root = TestDir::new("depth-limit");
let mut current = root.path().to_path_buf();
for index in 0..=OPERATION_DEPTH_LIMIT {
current = current.join(format!("d{index}"));
fs::create_dir(¤t).unwrap();
}
let error = measure_sources(&[root.path().to_path_buf()]).unwrap_err();
assert!(error.contains("depth limit"));
}
#[test]
fn progress_tracker_rejects_item_overflow() {
let (tx, _rx) = mpsc::sync_channel(1);
let mut tracker = ProgressTracker::new(
&tx,
SourceMeasure {
items: OPERATION_ITEM_LIMIT,
bytes: 0,
},
);
tracker.processed_items = OPERATION_ITEM_LIMIT;
assert!(tracker.bump_item(0).is_err());
}
#[test]
fn bounded_remove_deletes_nested_tree() {
let root = TestDir::new("bounded-remove");
let nested = root.path().join("a").join("b");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("file.txt"), b"x").unwrap();
let mut removed = 0usize;
remove_path_bounded(root.path(), |_| {
removed += 1;
Ok(())
})
.unwrap();
assert_eq!(removed, 4);
assert!(!root.path().exists());
}
#[test]
fn bounded_remove_rejects_depth_limit() {
let root = TestDir::new("remove-depth-limit");
let mut current = root.path().to_path_buf();
for index in 0..=OPERATION_DEPTH_LIMIT {
current = current.join(format!("d{index}"));
fs::create_dir(¤t).unwrap();
}
let error = remove_path(root.path()).unwrap_err();
assert!(error.to_string().contains("depth limit"));
assert!(root.path().exists());
}
#[test]
fn child_reaper_permits_are_bounded() {
let initial = active_child_reapers();
let available = CHILD_REAPER_LIMIT.saturating_sub(initial);
let mut permits = Vec::new();
for _ in 0..available {
permits.push(ChildReaperPermit::try_acquire().unwrap());
}
assert!(ChildReaperPermit::try_acquire().is_err());
drop(permits);
assert_eq!(active_child_reapers(), initial);
}
}