1. Introduction
When you hear “File Organizer,” you want to immediately write copyTo() and happily scatter files into folders like a cat scatters toys around an apartment. But bulk file operations are dangerous because they are irreversible (especially MOVE) and they often break in places you don’t expect: permissions, weird names, paths that are too long, files without extensions, nested directories, and so on.
That’s why we need discipline: first we traverse the source directory and collect a list of “candidates,” then we filter it by rules (for example, by extensions), and only then do we build a plan and execute operations. Today is only the first piece of the pipeline: “tree → candidates.”
A small diagram of what we are building (without executing operations for now):
flowchart TD
A[Source directory sourceDir] --> B[walkTopDown: get all tree nodes]
B --> C[filter: keep only isFile files]
C --> D[extensionOrNull: get the extension]
D --> E[filter: ext in allowedExt]
E --> F[relativize: relative path within sourceDir]
F --> G[Candidate: candidate data for further processing]
2. Tree traversal: walkTopDown() and the isFile filter
File and Path: why we need both
In Kotlin/JVM, working with files often looks like “grab java.io.File and go.” And it’s true: File is convenient for quick checks like exists(), isFile, isDirectory, for walkTopDown(), and for simple operations. You’ll see this style even in official examples for console scenarios.
But when paths matter as a structure rather than as strings, a second hero appears: java.nio.file.Path. Path has operations that are very important for us:
- relativize(...) — build a relative path “inside the base directory”;
- resolve(...) — join path parts without manual string concatenation and without guessing separators (/ vs \).
Today we critically need relativize(...). So we’ll live in a “hybrid world”: traversal via File, and relative path computation via Path. That’s fine: it’s not “two systems fighting,” it’s two handy tools for different tasks.
Basic traversal: collect all files
When we say “traverse a directory,” we mean: walk through all nested folders and see every file that is inside. In Kotlin, you can do this in different ways, but for our project the most practical and readable option is File.walkTopDown().
An important point: walkTopDown() returns a “walk over the tree,” where you’ll encounter both directories and files. At this stage, we don’t need directories as elements of the candidate list, so the first thing we do is keep only files.
Here is the minimal “collect all files no matter what” function:
import java.io.File
fun collectAllFiles(sourceDir: File): List<File> {
return sourceDir
.walkTopDown()
.filter { it.isFile }
.toList()
}
Notice two nuances.
The first nuance is that walkTopDown() doesn’t have to “load everything into memory immediately” — it traverses lazily, and toList() forces it to actually walk and collect the result. For beginners, this is good news: you write it “like a pipeline,” and Kotlin performs the pass when you ask for the final output.
The second nuance is that isFile filters out directories, but it doesn’t solve every problem in the world: symbolic links, special files, and access issues are outside today’s scope (and that’s okay). Our goal is a basic, robust prototype.
3. Extensions and filtering by allowedExt
File extension: careful and predictable
Extracting an extension seems trivial: “take everything after the dot.” In practice, this is one of those tasks where beginners reliably step on rakes — and it’s not embarrassing; file names don’t have to be “nice.”
Think about names like these:
- photo.JPG — the extension exists, but the casing is “shouting”;
- archive.tar.gz — multiple dots, and which extension is the “real” one?
- .bashrc — there is a dot, but it’s more of a “hidden name” than an extension;
- no_extension — no dot;
- weird. — there is a dot, but the extension is empty.
For File Organizer, we need a stable and predictable function: if the extension is valid, return a lowercase string; if not, return null.
Here is a careful version:
import java.io.File
fun extensionOrNull(file: File): String? {
val name = file.name
val dot = name.lastIndexOf('.')
if (dot <= 0) return null
if (dot == name.lastIndex) return null
return name.substring(dot + 1).lowercase()
}
Why dot <= 0?
- dot == -1 means “no dot” — so there is no extension.
- dot == 0 means “a dot at the very beginning” (for example, .bashrc) — in most utilities this is not considered an extension, and we won’t consider it either. Otherwise you’ll suddenly get an extension "bashrc", and then you’ll wonder why your “terminal config” flew into the documents category.
Why dot == lastIndex?
- Because weird. should not produce the extension "" (an empty string). An empty extension is a bad candidate; it’s easier to treat it as missing right away.
And what about archive.tar.gz?
- Our code returns gz. This is not “the truth,” it’s a rule. For today’s project this is enough: we’re building a prototype and not diving into composite extension detection (that’s a separate topic and a separate set of policies).
Filtering by extensions: so .JPG doesn’t ruin your life
Now that we can extract an extension, the rest is simple: keep only the files whose extension is in the allowedExt set from the configuration.
But there is a typical trap here: you can’t compare “as is.” The user might enter JPG, .Jpg, jpg , and the file on disk might be Photo.JPEG. So we need normalization in a single consistent style.
Assume that in the previous lecture you already wrote an extension normalizer:
fun normalizeExt(raw: String): String =
raw.trim().removePrefix(".").lowercase()
Then the rule is: in the configuration we store already-normalized extensions, and from the file we also extract a normalized one (we already do lowercase()), and the comparison becomes “boring and reliable.”
Here is candidate collection with a filter:
import java.io.File
fun collectCandidates(sourceDir: File, allowedExt: Set<String>): List<File> {
return sourceDir.walkTopDown()
.filter { it.isFile }
.filter { file ->
val ext = extensionOrNull(file) ?: return@filter false
ext in allowedExt
}
.toList()
}
Notice that we deliberately exclude files without an extension (null). This isn’t “the only correct way,” it’s a policy. If later you decide to support files without extensions, you’ll implement it as a separate rule, not “accidentally because split happened to behave that way.”
4. Relative paths and the candidate model
Relative path: why you need it
At this point beginners often have an honest question: “Why do we need a relative path if a file has file.absolutePath?”
Imagine the source directory is like this:
source/
cats/
cute.jpg
docs/
guide.pdf
If we store only the absolute path, we lose important information: where exactly inside source the file lived. And sometimes that’s useful to preserve the subfolder structure when organizing.
For example, (in the future) we want to get something like this:
target/
images/
cats/
cute.jpg
docs/
docs/
guide.pdf
To make this possible, we need to be able to say: “cute.jpg is located relative to source at cats/cute.jpg.” That is exactly what Path.relativize(...) gives you.
Minimal function:
import java.io.File
import java.nio.file.Path
fun relativePath(sourceDir: File, file: File): Path {
return sourceDir.toPath().relativize(file.toPath())
}
But there is a tricky nuance: relativize works correctly when both paths are of the “same kind” — either both absolute or both relative (and on the same file system). In real life, it’s better to normalize both paths to absolute.
A slightly more “bulletproof” version:
import java.io.File
import java.nio.file.Path
fun relativePath(sourceDir: File, file: File): Path {
val src = sourceDir.toPath().toAbsolutePath().normalize()
val f = file.toPath().toAbsolutePath().normalize()
return src.relativize(f)
}
It’s a bit longer, but you’re less likely to get weird effects if sourceDir was passed as . or ./source, while the file ends up being absolute.
Candidate model: collect data “enough, but not too much”
At this stage we already know how to:
- walk the tree;
- keep only files;
- extract an extension;
- filter by allowed extensions;
- compute a relative path.
Now it’s important to take one more “architecture” step: don’t drag “bare File” objects all over the project; instead, build a candidate model — a small data class that contains the fields you need.
Why is this important? Because later we’ll have different processing stages, and if in each place you recompute the extension and the relative path, you’ll start duplicating code and end up with mismatched rules (and that’s bugs’ favorite food).
Let’s build the model:
import java.io.File
import java.nio.file.Path
data class CandidateFile(
val file: File,
val ext: String,
val relative: Path
)
There is not “everything under the sun” here (for example, file size), because today we’re collecting the minimum needed for the next layout rules.
5. Collecting candidates from configuration
Now let’s assemble everything into one function that takes our configuration (from the previous lecture) and returns a list of candidates. Reminder of the idea: configuration is the utility’s contract, so it’s convenient when there is “one input.”
Assume we have:
data class OrganizerConfig(
val sourceDir: String,
val targetDir: String,
val mode: Mode,
val allowedExt: Set<String>,
val conflictPolicy: ConflictPolicy,
val dryRun: Boolean
)
Then the candidate collection function can look like this:
import java.io.File
fun collectCandidates(cfg: OrganizerConfig): List<CandidateFile> {
val sourceDir = File(cfg.sourceDir)
val srcPath = sourceDir.toPath().toAbsolutePath().normalize()
return sourceDir.walkTopDown()
.filter { it.isFile }
.mapNotNull { file ->
val ext = extensionOrNull(file) ?: return@mapNotNull null
if (ext !in cfg.allowedExt) return@mapNotNull null
val rel = srcPath.relativize(file.toPath().toAbsolutePath().normalize())
CandidateFile(file = file, ext = ext, relative = rel)
}
.toList()
}
Notice the style: we use mapNotNull so that “files that don’t fit” turn into null and are automatically dropped. This reads as “transform a file into a candidate, and if you can’t — just skip it.”
And yes, this is the moment when many people first realize that Kotlin collections are not just for and if, but also a “transformation pipeline.” Don’t worry: if you still read mapNotNull slowly, that’s normal. In a couple of days you’ll read chains like this noticeably more easily.
Mini diagnostics: verify we didn’t collect “everything”
In practice, after writing traversal you want to make sure the function doesn’t pull in, say, directories, and that allowedExt really works. The simplest way is to temporarily print the first few candidates to the console.
Let’s add a small fragment to main (that’s fine in a learning project):
fun main() {
val cfg = OrganizerConfig(
sourceDir = "input",
targetDir = "output",
mode = Mode.COPY,
allowedExt = setOf("jpg", "png", "pdf"),
conflictPolicy = ConflictPolicy.SKIP,
dryRun = true
)
val candidates = collectCandidates(cfg)
println("Candidates: ${candidates.size}") // Candidates: 3 (primer)
for (c in candidates.take(3)) {
println("${c.ext}: ${c.relative}") // jpg: cats/cute.jpg (primer)
}
}
There are two useful effects here.
First, you instantly see that relative looks like a “path inside source,” not a full absolute path on your machine.
Second, you can quickly spot extension normalization mistakes. If you suddenly see JPG instead of jpg, it means you forgot lowercase().
6. Typical mistakes
Mistake #1: traverse the tree but forget filter { it.isFile }, and then “process directories as files.”
It looks harmless until you try to extract an extension from a directory or “copy” a folder as a file. In the best case you get weird results; in the worst case you get exceptions and a half-broken run. The fix is simple: right after walkTopDown(), filter by isFile so that the rest of your code works only with what it can actually handle.
Mistake #2: extract the extension with split(".") and get unexpected values.
split creates many pieces, behaves poorly with names like archive.tar.gz, and also easily breaks on .bashrc or weird.. For a utility, predictability matters: it’s better to write a careful function once with lastIndexOf('.') and boundary checks than to later fix “why it’s sometimes null, sometimes an empty string, and sometimes it crashes.”
Mistake #3: compare extensions without normalization and wonder why photo.JPG doesn’t match jpg.
The file system is not required to help you be consistent. If you store jpg in allowedExt, but extract JPG from the file, the comparison honestly says “no match.” The fix is conceptual: normalize both the configuration and the file extension to the same form (usually lowercase() and “no dot”).
Mistake #4: build a relative path using strings (substring) and run into separator and length bugs.
Manual string work on paths is like fixing a watch with a hammer: sometimes it works, but it’s scary to watch. Windows and Linux use different separators, paths may have different “prefixes,” and it’s very easy to be off by one character. That’s why Path.relativize(...) exists: it does what you want, and it does it consistently across OSes.
Mistake #5: call relativize on “different kinds of paths” (one relative, one absolute) and get exceptions or weirdness.
This is a subtle point that people rarely guess on the first try. If sourceDir is given as ".", but the file is absolute, then relativize may not behave as you expect. A habit that saves nerves: before relativize, convert both paths to toAbsolutePath().normalize(), and then everything becomes stable and boring (and boring in I/O is a compliment).
Mistake #6: mix candidate collection with executing operations.
It’s very tempting to do copyTo right inside collectCandidates, “since we already found the file.” But that leads to code that is hard to test, hard to debug, and almost impossible to run safely in dry-run mode. Today’s principle is simple: traversal and candidate selection is a pure data-preparation step. It changes nothing on disk; it only returns a list of structured candidates.