use std::path::{Path, PathBuf};
use chrono::NaiveDateTime;
use percent_encoding::percent_decode_str;
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum TrashInfoError {
#[error("missing `[Trash Info]` group header; first non-blank line was {found:?}")]
MissingHeader {
found: String,
},
#[error("`.trashinfo` has no `Path=` key")]
MissingPath,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TrashInfo {
pub original_path: String,
pub deleted_at: Option<NaiveDateTime>,
}
pub fn parse_trashinfo(data: &[u8]) -> Result<TrashInfo, TrashInfoError> {
let text = String::from_utf8_lossy(strip_utf8_bom(data));
let mut lines = text.lines();
let header = lines
.by_ref()
.find(|line| !line.trim().is_empty())
.map_or("", str::trim);
if !header.eq_ignore_ascii_case("[Trash Info]") {
return Err(TrashInfoError::MissingHeader {
found: header.to_string(),
});
}
let mut path_enc: Option<&str> = None;
let mut date_raw: Option<&str> = None;
for line in lines {
let Some((key, value)) = line.split_once('=') else {
continue;
};
let (key, value) = (key.trim(), value.trim());
if path_enc.is_none() && key == "Path" {
path_enc = Some(value);
} else if date_raw.is_none() && key == "DeletionDate" {
date_raw = Some(value);
}
}
let Some(path_enc) = path_enc else {
return Err(TrashInfoError::MissingPath);
};
let original_path = percent_decode_str(path_enc)
.decode_utf8_lossy()
.into_owned();
let deleted_at = date_raw.and_then(parse_deletion_date);
Ok(TrashInfo {
original_path,
deleted_at,
})
}
fn strip_utf8_bom(data: &[u8]) -> &[u8] {
data.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(data)
}
fn parse_deletion_date(value: &str) -> Option<NaiveDateTime> {
const FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
if let Ok(dt) = NaiveDateTime::parse_from_str(value, FORMAT) {
return Some(dt);
}
let (date, time) = value.split_once('T')?;
if date.len() == 8 && date.bytes().all(|b| b.is_ascii_digit()) {
let normalised = format!("{}-{}-{}T{}", &date[0..4], &date[4..6], &date[6..8], time);
return NaiveDateTime::parse_from_str(&normalised, FORMAT).ok();
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrashEntry {
pub info_path: PathBuf,
pub content_path: Option<PathBuf>,
}
pub fn scan_trash(trash_dir: &Path) -> std::io::Result<Vec<TrashEntry>> {
let info_dir = trash_dir.join("info");
let files_dir = trash_dir.join("files");
let mut entries = Vec::new();
for entry in std::fs::read_dir(&info_dir)? {
let entry = entry?; let name = entry.file_name();
let Some(name) = name.to_str() else {
continue; };
let Some(stem) = name.strip_suffix(".trashinfo") else {
continue;
};
let candidate = files_dir.join(stem);
let content_path = candidate.exists().then_some(candidate);
entries.push(TrashEntry {
info_path: entry.path(),
content_path,
});
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
fn naive(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> NaiveDateTime {
NaiveDate::from_ymd_opt(y, mo, d)
.unwrap()
.and_hms_opt(h, mi, s)
.unwrap()
}
#[test]
fn parses_spec_example() {
let data = b"[Trash Info]\nPath=foo/bar/meow.bow-wow\nDeletionDate=20040831T22:32:08\n";
let info = parse_trashinfo(data).unwrap();
assert_eq!(info.original_path, "foo/bar/meow.bow-wow");
assert_eq!(info.deleted_at, Some(naive(2004, 8, 31, 22, 32, 8)));
}
#[test]
fn parses_extended_date_and_percent_decodes_path() {
let data =
b"[Trash Info]\nPath=/home/u/My%20Docs/r%C3%A9sum%C3%A9.pdf\nDeletionDate=2024-01-15T13:45:09\n";
let info = parse_trashinfo(data).unwrap();
assert_eq!(info.original_path, "/home/u/My Docs/résumé.pdf");
assert_eq!(info.deleted_at, Some(naive(2024, 1, 15, 13, 45, 9)));
}
#[test]
fn plus_is_literal_not_space() {
let data = b"[Trash Info]\nPath=/tmp/a+b.txt\n";
let info = parse_trashinfo(data).unwrap();
assert_eq!(info.original_path, "/tmp/a+b.txt");
}
#[test]
fn first_path_and_date_win() {
let data = b"[Trash Info]\nPath=/first\nDeletionDate=2024-01-01T00:00:00\nPath=/second\nDeletionDate=2025-06-06T06:06:06\n";
let info = parse_trashinfo(data).unwrap();
assert_eq!(info.original_path, "/first");
assert_eq!(info.deleted_at, Some(naive(2024, 1, 1, 0, 0, 0)));
}
#[test]
fn case_insensitive_header_accepted() {
let data = b"[Trash info]\nPath=/x\n";
let info = parse_trashinfo(data).unwrap();
assert_eq!(info.original_path, "/x");
}
#[test]
fn missing_header_is_error() {
let data = b"Path=/x\n";
let err = parse_trashinfo(data).unwrap_err();
assert!(matches!(err, TrashInfoError::MissingHeader { found } if found == "Path=/x"));
}
#[test]
fn missing_path_is_error() {
let data = b"[Trash Info]\nDeletionDate=2024-01-15T13:45:09\n";
assert_eq!(
parse_trashinfo(data).unwrap_err(),
TrashInfoError::MissingPath
);
}
#[test]
fn unparseable_date_is_none() {
let data = b"[Trash Info]\nPath=/x\nDeletionDate=not-a-date\n";
let info = parse_trashinfo(data).unwrap();
assert_eq!(info.original_path, "/x");
assert_eq!(info.deleted_at, None);
}
#[test]
fn missing_date_is_none() {
let data = b"[Trash Info]\nPath=/x\n";
assert_eq!(parse_trashinfo(data).unwrap().deleted_at, None);
}
#[test]
fn bom_and_crlf_tolerated() {
let data = b"\xEF\xBB\xBF[Trash Info]\r\nPath=/x\r\nDeletionDate=2024-01-15T13:45:09\r\n";
let info = parse_trashinfo(data).unwrap();
assert_eq!(info.original_path, "/x");
assert_eq!(info.deleted_at, Some(naive(2024, 1, 15, 13, 45, 9)));
}
#[test]
fn scan_trash_pairs_info_to_files() {
let dir = std::env::temp_dir().join(format!("trash-core-linux-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("info")).unwrap();
std::fs::create_dir_all(dir.join("files")).unwrap();
std::fs::write(
dir.join("info/report.pdf.trashinfo"),
b"[Trash Info]\nPath=/x\n",
)
.unwrap();
std::fs::write(dir.join("files/report.pdf"), b"data").unwrap();
std::fs::write(
dir.join("info/gone.txt.trashinfo"),
b"[Trash Info]\nPath=/y\n",
)
.unwrap();
std::fs::write(dir.join("info/notes.md"), b"x").unwrap();
let mut entries = scan_trash(&dir).unwrap();
entries.sort_by_key(|e| e.info_path.clone());
assert_eq!(entries.len(), 2);
let paired = entries
.iter()
.find(|e| e.info_path.ends_with("report.pdf.trashinfo"))
.unwrap();
assert!(paired
.content_path
.as_ref()
.unwrap()
.ends_with("report.pdf"));
let orphan = entries
.iter()
.find(|e| e.info_path.ends_with("gone.txt.trashinfo"))
.unwrap();
assert!(orphan.content_path.is_none());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn junk_line_skipped_and_basic_date_fallthrough() {
let data =
b"[Trash Info]\n; a comment line without an equals sign\nPath=/x\nDeletionDate=12345T00:00:00\n";
let info = parse_trashinfo(data).unwrap();
assert_eq!(info.original_path, "/x");
assert_eq!(info.deleted_at, None);
}
}