Skip to content

Script: Organize Your Downloads

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:

  1. A pure decision: given an extension like "jpg", which folder does it belong in? This is just a lookup with a sensible default.
  2. 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.

Terminal window
lash --test organize.lash # the unittests only
lash organize.lash --help # auto-generated help

The --test flag runs only the unittest { } blocks; the fn main body is skipped, so the tests don’t touch the filesystem at all.


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 subfolder
unittest {
// 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 one
unittest {
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()
}

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.


#!/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 subfolder
unittest {
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 one
unittest {
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:

Terminal window
lash --test organize.lash # the unittests
lash organize.lash --help # help
lash organize.lash ~/Downloads true # dry run first
lash 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 skipped

The doc-comment plus typed parameters become the --help:

Terminal window
lash organize.lash --help
organize.lash — Sort files in a directory into subfolders by extension.
Arguments:
dir (string)
dry_run (bool) default: false