NAME
App::FileSync::Trash - dated trash with retention
SYNOPSIS
my $where = App::FileSync::Trash::move_to_trash(
src => $abs, rel => 'notes/a.txt', root => $trash_dir,
);
my $versions = App::FileSync::Trash::versions(root => $trash_dir, rel => 'notes/a.txt');
App::FileSync::Trash::restore(src => $versions->[0], to => $abs);
App::FileSync::Trash::prune(root => $trash_dir, days => 30);
FUNCTIONS
move_to_trash
Purpose: move a file or directory into today's trash bucket.
Args: src (absolute path), rel (its relative path, kept inside the bucket), root (the trash directory), optional epoch (default now).
Returns: the absolute path it was moved to. A second deletion of the same path on the same day is kept as PATH.1, then PATH.2, and so on.
Side Effects: renames src; across filesystems a file is copied (keeping its mtime) and the original removed.
Usage:
my $where = App::FileSync::Trash::move_to_trash(src => $abs, rel => $rel, root => $trash);
EXAMPLE
App::FileSync::Trash::move_to_trash(
src => '/home/foo/Documents/old.txt', rel => 'old.txt',
root => '/home/foo/.filesync/trash/documents', epoch => time,
);
API SPECIFICATION
INPUT
{
src => { type => 'string', min => 1 },
rel => { type => 'string', min => 1 },
root => { type => 'string', min => 1 },
epoch => { type => 'number', optional => 1 },
}
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
Key Meaning Resolution
trash.move the entry could not be moved check permissions
io.mkdir the bucket could not be created check permissions
FORMAL SPECIFICATION
ΔFS
src?, rel?, root? : PATH ; t? : ℕ ; dst! : PATH
let b = root? / day(t?) / rel?
dst! = b if b ¬in; dom FS, else b ⁀ "." ⁀ min { n : ℕ₁ | b ⁀ "." ⁀ n ¬in; dom FS }
FS'(dst!) = FS(src?) ∧ src? ¬in; dom FS'
prune
Purpose: remove day buckets older than the retention period.
Args: root, optional days (default 30; 0 or less disables pruning), now (default the current time), dry_run.
Returns: an arrayref of the bucket directories removed (or that would be).
Side Effects: removes whole day directories. A bucket that cannot be removed is left and not reported.
Usage:
my $removed = App::FileSync::Trash::prune(root => $trash, days => 30);
EXAMPLE
my $would = App::FileSync::Trash::prune(root => $trash, days => 7, dry_run => 1);
print "$_\n" for @{$would};
API SPECIFICATION
INPUT
{
root => { type => 'string', optional => 1 },
days => { type => 'number', optional => 1 },
now => { type => 'number', optional => 1 },
dry_run => { type => 'boolean', optional => 1 },
}
OUTPUT
{ type => 'arrayref' }
MESSAGES
None.
FORMAL SPECIFICATION
ΔFS
Old == { b : buckets(root) | midnight(b) ≤ now − days × 86400 }
dry_run ⇒ FS' = FS ∧ result = Old
¬dry_run ⇒ result ⊆ Old ∧ ∀ b : result • b ¬in; dom FS'
versions
Purpose: every stored version of a path.
Args: root, rel.
Returns: an arrayref of absolute paths, newest first: by day, then by the same-day suffix (.2 before .1 before the unsuffixed first deletion).
Side Effects: none.
Usage:
my $versions = App::FileSync::Trash::versions(root => $trash, rel => 'notes/a.txt');
EXAMPLE
my ($newest) = @{ App::FileSync::Trash::versions(root => $trash, rel => 'a.txt') };
API SPECIFICATION
INPUT
{
root => { type => 'string', optional => 1 },
rel => { type => 'string', min => 1 },
}
OUTPUT
{ type => 'arrayref' }
MESSAGES
Key Meaning Resolution
args.invalid no rel was given pass the path
FORMAL SPECIFICATION
versions(root, rel) = sort_desc(day, suffix)
{ v | ∃ b : buckets(root) • v = b/rel ∨ ∃ n : ℕ₁ • v = b/rel ⁀ "." ⁀ n }
restore
Purpose: copy one trashed version out, keeping modes and mtimes.
Args: src (a path from "versions"), to (the destination; must not exist).
Returns: to.
Side Effects: copies a file, or a directory tree, to to. The trash entry is left in place, so the same version can be restored twice.
Usage:
App::FileSync::Trash::restore(src => $versions->[0], to => $abs);
EXAMPLE
App::FileSync::Trash::restore(src => $version, to => '/tmp/recovered.txt');
API SPECIFICATION
INPUT
{
src => { type => 'string', min => 1 },
to => { type => 'string', min => 1 },
}
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
Key Meaning Resolution
trash.missing the version is not there list versions again
trash.exists the destination already exists move it aside
trash.cannot_stat an entry cannot be examined check permissions
trash.not_regular a special file is in the version restore by hand
io.copy a file could not be copied check space
FORMAL SPECIFICATION
ΔFS
pre src ∈ dom FS ∧ to ¬in; dom FS
post tree'(to) = tree(src) ∧ src ∈ dom FS'
date_of
Purpose: the trash day a stored path belongs to.
Args: $path, as returned by "versions" or "move_to_trash".
Returns: YYYY-MM-DD, or undef for a path outside a day bucket. Both / and \ separators are understood.
Side Effects: none.
Usage:
my $date = App::FileSync::Trash::date_of($version);
EXAMPLE
print App::FileSync::Trash::date_of('/t/trash/2026/10/22/a/b.txt'), "\n"; # 2026-10-22
API SPECIFICATION
INPUT
[ { name => 'path', type => 'string', optional => 1 } ]
OUTPUT
{ type => 'string', optional => 1, matches => qr/\A\d{4}-\d{2}-\d{2}\z/ }
MESSAGES
None.
FORMAL SPECIFICATION
date_of(p ⁀ sep ⁀ yyyy ⁀ sep ⁀ mm ⁀ sep ⁀ dd ⁀ sep ⁀ q) = yyyy ⁀ "-" ⁀ mm ⁀ "-" ⁀ dd
version_info
Purpose: describe one trashed version for a listing.
Args: $path, a path from "versions".
Returns: { path, date, size, mtime }; size and mtime are undef if the version has gone since it was listed.
Side Effects: stats the path.
Usage:
my @rows = map { App::FileSync::Trash::version_info($_) } @{$versions};
EXAMPLE
my $info = App::FileSync::Trash::version_info($versions->[0]);
print "$info->{date} $info->{size} bytes\n";
API SPECIFICATION
INPUT
[ { name => 'path', type => 'string', min => 1 } ]
OUTPUT
{
type => 'hashref',
schema => {
path => { type => 'string' },
date => { type => 'string', optional => 1 },
size => { type => 'integer', optional => 1 },
mtime => { type => 'number', optional => 1 },
},
}
MESSAGES
None.
FORMAL SPECIFICATION
version_info(p) = ⟨path ↦ p, date ↦ date_of(p), size ↦ size(FS(p)), mtime ↦ mtime(FS(p))⟩
LIMITATIONS
A same-day collision is stored as
PATH.N. A real file whose name already ends in.N(backup.1) deleted the same day asbackupis therefore listed as a version ofbackuptoo.Moving a directory across filesystems is not supported; files are copied and removed, directories must rename.
Pruning works in whole UTC days, so retention is accurate to a day.
AUTHOR
Nigel Horne, <njh at nigelhorne.com>
LICENCE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.