use std::ffi::OsString;
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Path, PathBuf};
use super::ops::{self, FileChange, FileError};
const TRIES: u32 = 1_000;
#[must_use]
pub(super) fn home_trash() -> Option<PathBuf> {
if cfg!(windows) || cfg!(target_os = "macos") {
return None;
}
crate::storage::data_dir("Trash")
}
fn files(trash: &Path) -> PathBuf {
trash.join("files")
}
fn info(trash: &Path) -> PathBuf {
trash.join("info")
}
fn note(trash: &Path, name: &str) -> Note {
let written = fs::read_to_string(info(trash).join(format!("{name}.trashinfo"))).unwrap_or_default();
let mut origin = None;
let mut deleted = None;
for line in written.lines() {
if let Some(written) = line.strip_prefix("Path=") {
origin = decode_path(written).filter(|path| path.is_absolute());
} else if let Some(stamp) = line.strip_prefix("DeletionDate=").map(str::trim).filter(|it| !it.is_empty()) {
deleted = Some(stamp.to_owned());
}
}
Note { origin, deleted }
}
struct Note {
origin: Option<PathBuf>,
deleted: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Trashed {
pub name: String,
pub label: String,
pub origin: Option<PathBuf>,
pub deleted: Option<String>,
pub folder: bool,
pub executable: bool,
}
impl Trashed {
#[must_use]
pub fn origin_text(&self) -> String {
let Some(origin) = &self.origin else { return crate::t!("quvyta.file-manager.origin-unknown") };
origin.parent().unwrap_or(origin).to_string_lossy().into_owned()
}
#[must_use]
pub fn deleted_text(&self) -> String {
let Some(deleted) = &self.deleted else { return String::new() };
match deleted.split_once('T') {
Some((date, time)) if time.get(..5).is_some() => format!("{date} {}", &time[..5]),
_ => deleted.clone(),
}
}
#[must_use]
pub fn deleted_at(&self) -> Option<i64> {
let (date, time) = self.deleted.as_deref()?.split_once('T')?;
let moment = crate::date::DateTime {
date: crate::date::Date::parse(date).ok()?,
time: crate::date::TimeOfDay::parse(time)?,
offset_minutes: crate::date::local_offset_minutes(),
};
Some(moment.to_unix())
}
}
pub(super) fn entries(trash: &Path) -> Result<Vec<Trashed>, FileError> {
if !files(trash).exists() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for entry in fs::read_dir(files(trash)).map_err(ops::read_error)? {
let entry = entry.map_err(ops::read_error)?;
let own = entry.file_name();
let name = own.to_string_lossy().into_owned();
let folder = entry.file_type().is_ok_and(|kind| kind.is_dir());
let executable = !folder && super::state::runs(&entry, &name);
let note = note(trash, &name);
let label = note.origin.as_deref().and_then(Path::file_name).unwrap_or(own.as_os_str());
entries.push(Trashed {
name,
label: label.to_string_lossy().into_owned(),
origin: note.origin,
deleted: note.deleted,
folder,
executable,
});
}
Ok(entries)
}
pub(super) fn move_to_trash(trash: &Path, path: &Path) -> Result<String, FileError> {
for folder in [files(trash), info(trash)] {
fs::create_dir_all(&folder).map_err(|_| FileError::NoTrash)?;
}
let name = path.file_name().ok_or(FileError::NoTrash)?.to_string_lossy().into_owned();
let (taken, note) = reserve(&info(trash), &name)?;
if let Err(problem) = write_info(¬e, path) {
let _ = fs::remove_file(¬e);
return Err(problem);
}
match fs::rename(path, files(trash).join(&taken)) {
Ok(()) => Ok(taken),
Err(problem) => {
let _ = fs::remove_file(¬e);
Err(if problem.kind() == ErrorKind::CrossesDevices { FileError::NoTrash } else { problem.into() })
}
}
}
fn reserve(info: &Path, name: &str) -> Result<(String, PathBuf), FileError> {
for attempt in 0..TRIES {
let taken = if attempt == 0 { name.to_owned() } else { format!("{name}.{attempt}") };
let note = info.join(format!("{taken}.trashinfo"));
match OpenOptions::new().write(true).create_new(true).open(¬e) {
Ok(_) => return Ok((taken, note)),
Err(problem) if problem.kind() == ErrorKind::AlreadyExists => {}
Err(problem) => return Err(problem.into()),
}
}
Err(FileError::NoTrash)
}
fn write_info(note: &Path, path: &Path) -> Result<(), FileError> {
let now = crate::date::DateTime::now_local();
let date = now.date;
let stamp = format!("{:04}-{:02}-{:02}T{}", date.year(), date.month(), date.day(), now.time);
let text = format!("[Trash Info]\nPath={}\nDeletionDate={stamp}\n", encode_path(path));
let mut file = OpenOptions::new().write(true).truncate(true).open(note)?;
file.write_all(text.as_bytes())?;
file.flush()?;
Ok(())
}
pub(super) fn restore(trash: &Path, name: &str, wanted: Option<&str>) -> Result<FileChange, FileError> {
if let Some(wanted) = wanted {
ops::check_name(wanted, std::iter::empty(), None).map_err(FileError::Name)?;
}
one_entry(name)?;
let note = note(trash, name);
let Some(origin) = note.origin else { return Err(FileError::NoOrigin) };
let own = origin.file_name().unwrap_or_default().to_string_lossy().into_owned();
let to = origin.parent().unwrap_or(&origin).join(wanted.unwrap_or(&own));
if fs::symlink_metadata(&to).is_ok() {
return Err(FileError::TakenAs(free_name(&to)));
}
fs::rename(files(trash).join(name), &to)?;
let _ = fs::remove_file(info(trash).join(format!("{name}.trashinfo")));
Ok(FileChange::Restored(name.to_owned()))
}
pub(super) fn purge(trash: &Path, name: &str) -> Result<FileChange, FileError> {
one_entry(name)?;
let entry = files(trash).join(name);
if fs::symlink_metadata(&entry)?.is_dir() {
fs::remove_dir_all(&entry)?;
} else {
fs::remove_file(&entry)?;
}
let _ = fs::remove_file(info(trash).join(format!("{name}.trashinfo")));
Ok(FileChange::Deleted(name.to_owned()))
}
pub(super) fn empty(trash: &Path) -> (Vec<String>, Option<FileError>) {
if !files(trash).exists() {
return (Vec::new(), None);
}
let mut names: Vec<String> = match fs::read_dir(files(trash)) {
Ok(entries) => entries.flatten().map(|entry| entry.file_name().to_string_lossy().into_owned()).collect(),
Err(problem) => return (Vec::new(), Some(ops::read_error(problem))),
};
names.sort();
let mut deleted = Vec::new();
for name in &names {
match purge(trash, name) {
Ok(_) => deleted.push(name.clone()),
Err(problem) => return (deleted, Some(problem)),
}
}
(deleted, None)
}
fn one_entry(name: &str) -> Result<(), FileError> {
ops::check_name(name, std::iter::empty(), None).map_err(|_| FileError::Outside)
}
fn free_name(to: &Path) -> String {
let Some(folder) = to.parent() else { return to.to_string_lossy().into_owned() };
let first = to.file_name().unwrap_or_default().to_string_lossy().into_owned();
(0..TRIES)
.map(|attempt| if attempt == 0 { first.clone() } else { format!("{first}.{attempt}") })
.find(|name| fs::symlink_metadata(folder.join(name)).is_err())
.unwrap_or(first)
}
fn encode_path(path: &Path) -> String {
let mut encoded = String::new();
for byte in path.as_os_str().as_bytes() {
let plain = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/');
if plain {
encoded.push(char::from(*byte));
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn decode_path(written: &str) -> Option<PathBuf> {
let bytes = written.as_bytes();
let mut decoded: Vec<u8> = Vec::with_capacity(bytes.len());
let mut at = 0;
while at < bytes.len() {
match bytes[at] {
b'%' => {
let escape = written.get(at + 1..at + 3)?;
decoded.push(u8::from_str_radix(escape, 16).ok()?);
at += 3;
}
byte => {
decoded.push(byte);
at += 1;
}
}
}
Some(PathBuf::from(OsString::from_vec(decoded)))
}