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 as backup is therefore listed as a version of backup too.

  • 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.