use chrono::{DateTime, TimeZone, Utc};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TrashState {
Trashed,
Pending,
}
impl TrashState {
#[must_use]
pub fn default_retention_secs(self) -> i64 {
const SECONDS_PER_DAY: i64 = 86_400;
match self {
TrashState::Trashed => 30 * SECONDS_PER_DAY,
TrashState::Pending => 7 * SECONDS_PER_DAY,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TrashedName {
pub state: TrashState,
pub date_expires: i64,
pub original_name: String,
}
impl TrashedName {
#[must_use]
pub fn expires_at(&self) -> Option<DateTime<Utc>> {
Utc.timestamp_opt(self.date_expires, 0).single()
}
#[must_use]
pub fn inferred_deleted_at(&self) -> Option<DateTime<Utc>> {
let secs = self
.date_expires
.checked_sub(self.state.default_retention_secs())?;
Utc.timestamp_opt(secs, 0).single()
}
}
#[must_use]
pub fn parse_trashed_name(name: &str) -> Option<TrashedName> {
let rest = name.strip_prefix('.')?;
let (state, after) = strip_state_prefix(rest)?;
let (digits, original) = after.split_once('-')?;
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let date_expires = digits.parse::<i64>().ok()?;
if original.is_empty() || original.contains('/') {
return None;
}
Some(TrashedName {
state,
date_expires,
original_name: original.to_string(),
})
}
fn strip_state_prefix(rest: &str) -> Option<(TrashState, &str)> {
for (prefix, state) in [
("trashed-", TrashState::Trashed),
("pending-", TrashState::Pending),
] {
let Some(head) = rest.get(..prefix.len()) else {
continue;
};
if head.eq_ignore_ascii_case(prefix) {
return rest.get(prefix.len()..).map(|tail| (state, tail));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn at(secs: i64) -> DateTime<Utc> {
Utc.timestamp_opt(secs, 0).single().unwrap()
}
#[test]
fn decodes_trashed() {
let t = parse_trashed_name(".trashed-1700000000-photo.jpg").unwrap();
assert_eq!(t.state, TrashState::Trashed);
assert_eq!(t.date_expires, 1_700_000_000);
assert_eq!(t.original_name, "photo.jpg");
}
#[test]
fn decodes_pending() {
let t = parse_trashed_name(".pending-1700000000-clip.mp4").unwrap();
assert_eq!(t.state, TrashState::Pending);
}
#[test]
fn original_name_keeps_dashes_and_dots() {
let t = parse_trashed_name(".trashed-1700000000-my-holiday.2024-04.jpg").unwrap();
assert_eq!(t.original_name, "my-holiday.2024-04.jpg");
}
#[test]
fn prefix_is_case_insensitive() {
let t = parse_trashed_name(".TrAsHeD-1700000000-IMG_0001.HEIC").unwrap();
assert_eq!(t.state, TrashState::Trashed);
assert_eq!(t.original_name, "IMG_0001.HEIC");
}
#[test]
fn ignores_non_trashed_names() {
assert!(parse_trashed_name("photo.jpg").is_none());
assert!(parse_trashed_name(".hidden").is_none());
assert!(parse_trashed_name("invoice-2024-final.pdf").is_none());
}
#[test]
fn rejects_non_numeric_expiry() {
assert!(parse_trashed_name(".trashed-abc-photo.jpg").is_none());
}
#[test]
fn rejects_slash_in_name() {
assert!(parse_trashed_name(".trashed-1700000000-a/b.jpg").is_none());
}
#[test]
fn rejects_missing_fields() {
assert!(parse_trashed_name(".trashed-1700000000-").is_none());
assert!(parse_trashed_name(".trashed--photo.jpg").is_none());
}
#[test]
fn timestamps_decode_and_infer() {
let t = parse_trashed_name(".trashed-1700000000-photo.jpg").unwrap();
assert_eq!(t.expires_at(), Some(at(1_700_000_000)));
assert_eq!(
t.inferred_deleted_at(),
Some(at(1_700_000_000 - 30 * 86_400))
);
}
#[test]
fn pending_retention_and_inference() {
let t = parse_trashed_name(".pending-1700000000-x.tmp").unwrap();
assert_eq!(t.state.default_retention_secs(), 7 * 86_400);
assert_eq!(
t.inferred_deleted_at(),
Some(at(1_700_000_000 - 7 * 86_400))
);
}
}