Script: Organize Your Downloads
What’s a file organizer?
Section titled “What’s a file organizer?”Everyone’s Downloads folder turns into a junkyard. A file organizer
is a small script that sweeps through one directory and sorts every
file into a subfolder by type — images/, videos/, documents/,
and so on.
The work splits cleanly into two pieces:
- A pure decision: given an extension like
"jpg", which folder does it belong in? This is just a lookup with a sensible default. - The I/O: loop through the files, ask the function above, and move them.
We’ll write the decision function test-first and add a --dry_run
flag so the user can preview before committing to filesystem changes.
Build organize.lash. Given a directory path, group every file in it
into a subfolder named after its category. Provide a dry-run flag for
preview.
lash --test organize.lash # the unittests onlylash organize.lash --help # auto-generated helpThe --test flag runs only the unittest { } blocks; the fn main
body is skipped, so the tests don’t touch the filesystem at all.
Step 1: Decide where each file type goes
Section titled “Step 1: Decide where each file type goes”Every category — what counts as an image, what counts as a document —
is a decision we want to lock down explicitly. The natural shape of
that decision is a match expression, which we’ll wrap in a function
and test row-by-row.
/// folder_for routes a file extension to its destination subfolderunittest { // images folder_for("jpg").must.equal("images") folder_for("png").must.equal("images")
// documents folder_for("pdf").must.equal("documents") folder_for("txt").must.equal("documents")
// archives folder_for("zip").must.equal("archives") folder_for("tar").must.equal("archives")
// unknown extensions get a sentinel folder_for("xyz").must.equal("other") folder_for("lash").must.equal("other")}The last two rows are the important ones: extensions you didn’t think
of must go somewhere, and the test pins down that they end up in
other/ rather than crashing the script. Run
lash --test organize.lash; it fails (no folder_for yet). Now write it:
fn folder_for(ext) { return match ext { "jpg" | "jpeg" | "png" | "gif" | "webp" | "svg" => "images" "mp4" | "mkv" | "avi" | "mov" => "videos" "mp3" | "flac" | "wav" | "ogg" => "music" "pdf" | "doc" | "docx" | "txt" | "odt" => "documents" "zip" | "tar" | "gz" | "7z" | "rar" => "archives" "deb" | "rpm" | "AppImage" => "installers" _ => "other" }}match is an expression — every arm produces a value, the last
arm _ is the wildcard catch-all. No need for a mutable variable to
collect the result.
Re-run the suite. All eight assertions pass.
Step 2: Pull the extension out of a filename
Section titled “Step 2: Pull the extension out of a filename”The main body needs to convert "vacation.jpg" into "jpg".
That’s a one-liner, but it has subtle cases worth testing:
/// extension extracts the lowercased extension or "" if there isn't oneunittest { extension("vacation.jpg").must.equal("jpg") extension("Photo.JPG").must.equal("jpg") // lowercased extension("archive.tar.gz").must.equal("gz") // last segment wins extension("Makefile").must.equal("") // no dot — empty}Why each row: lowercasing makes JPG and jpg route to the same
folder; multi-extension files like archive.tar.gz route by the last
segment; files with no extension don’t crash and get an empty string
that folder_for("") will route to other.
fn extension(name) { let parts = name.split(".") if parts.length < 2 { return "" } return parts[parts.length - 1].toLower()}Step 3: Wire it into a main
Section titled “Step 3: Wire it into a main”The runtime body is straightforward looping. We don’t unittest main
— it touches the filesystem. The pure functions above already have
tests.
fn main(dir: string, dry_run: bool = false) { mut moved = 0 mut skipped = 0
for entry in `ls $dir` { let path = "$dir/$entry" let check = `test -f $path`.capture if check.isFailure { skipped = skipped + 1 continue }
let ext = extension(entry) if ext == "" { skipped = skipped + 1 continue }
let dest = folder_for(ext) let dest_dir = "$dir/$dest"
if dry_run { echo " $entry -> $dest/" } else { mkdir -p $dest_dir mv "$path" "$dest_dir/$entry" } moved = moved + 1 }
if dry_run { echo "" echo "(dry run -- nothing was moved)" } echo "$moved files organized, $skipped skipped"}Two things to notice: the dry-run branch is a single if, and the
counters are mut because they change on every iteration.
Complete script
Section titled “Complete script”#!/usr/bin/env lash
/// Sort files in a directory into subfolders by extension.fn main(dir: string, dry_run: bool = false) { mut moved = 0 mut skipped = 0
for entry in `ls $dir` { let path = "$dir/$entry" let check = `test -f $path`.capture if check.isFailure { skipped = skipped + 1 continue }
let ext = extension(entry) if ext == "" { skipped = skipped + 1 continue }
let dest = folder_for(ext) let dest_dir = "$dir/$dest"
if dry_run { echo " $entry -> $dest/" } else { mkdir -p $dest_dir mv "$path" "$dest_dir/$entry" } moved = moved + 1 }
if dry_run { echo "" echo "(dry run -- nothing was moved)" } echo "$moved files organized, $skipped skipped"}
fn folder_for(ext) { return match ext { "jpg" | "jpeg" | "png" | "gif" | "webp" | "svg" => "images" "mp4" | "mkv" | "avi" | "mov" => "videos" "mp3" | "flac" | "wav" | "ogg" => "music" "pdf" | "doc" | "docx" | "txt" | "odt" => "documents" "zip" | "tar" | "gz" | "7z" | "rar" => "archives" "deb" | "rpm" | "AppImage" => "installers" _ => "other" }}
fn extension(name) { let parts = name.split(".") if parts.length < 2 { return "" } return parts[parts.length - 1].toLower()}
/// folder_for routes a file extension to its destination subfolderunittest { folder_for("jpg").must.equal("images") folder_for("png").must.equal("images") folder_for("pdf").must.equal("documents") folder_for("txt").must.equal("documents") folder_for("zip").must.equal("archives") folder_for("tar").must.equal("archives") folder_for("xyz").must.equal("other") folder_for("lash").must.equal("other")}
/// extension extracts the lowercased extension or "" if there isn't oneunittest { extension("vacation.jpg").must.equal("jpg") extension("Photo.JPG").must.equal("jpg") extension("archive.tar.gz").must.equal("gz") extension("Makefile").must.equal("")}Run it. Always start with the dry run when pointing the script at a folder you care about:
lash --test organize.lash # the unittestslash organize.lash --help # helplash organize.lash ~/Downloads true # dry run firstlash organize.lash ~/Downloads # really move files report.pdf -> documents/ vacation.jpg -> images/ album.zip -> archives/ notes.txt -> documents/ video.mp4 -> videos/
(dry run -- nothing was moved)5 files organized, 2 skippedThe doc-comment plus typed parameters become the --help:
lash organize.lash --helporganize.lash — Sort files in a directory into subfolders by extension.
Arguments: dir (string) dry_run (bool) default: false