JavaRush/Courses/Kotlin SELF/File Organizer Project

File Organizer Project

Open

1. Introduction

If you’ve ever tried to “just write a utility that sorts files into folders,” you know how it usually ends: 12 parameters, 7 boolean flags, and a magic string "backup2_final_final" suddenly appear in the code. A contract is a way to agree in advance on what exactly the program does and how it should behave in disputed situations—so that later you don’t argue with yourself while looking at if (flag) { ... } else { ... }.

In this lecture we will formalize the File Organizer contract: which input parameters it accepts, which modes it supports, what it does on name conflicts, and what we consider correct operation. In the next project steps we will traverse directories, build a distribution plan, and execute operations. But without a contract, it’s like renovating an apartment without a blueprint: “it will be nice here, and why a wire is sticking out of the wall—we’ll figure that out later.”

2. What is a contract for a console utility

A utility contract is not a legal document, but a practical specification. It answers questions: what goes in, what comes out, what rules, what guarantees, what constraints. If you express this as types and data structures, the Kotlin compiler becomes your ally: it doesn’t just “build the program,” it helps you avoid doing something silly.

For File Organizer, it’s convenient to think of the contract as three layers:

  1. Configuration — what the user passed to the utility: where to read from, where to put things, how to act.
  2. Policies — how we resolve ambiguities: what to do on conflicts, how to treat files without an extension, what counts as an error.
  3. Correctness criteria — what we consider successful work, which results we record, and what statuses look like.

In this lecture we’ll focus on the first and second, and just a little on the third, to prepare the ground for reporting and safe bulk operations.

3. Minimal set of input parameters

Before drawing classes and enum, it’s useful to write out in plain language which parameters the utility needs to work predictably. It’s important not to overdo it: the more parameters, the more ways to run the program incorrectly. But too few—and behavior becomes “magical,” and magic in I/O usually ends in drama.

This set is enough for us:

Parameter Meaning Example
sourceDir
source directory where we look for files
./downloads
targetDir
destination directory (root)
./organized
mode
copy or move
COPY / MOVE
allowedExt
which extensions we care about
{"jpg","png","pdf"}
conflictPolicy
what to do if the target file already exists
SKIP / BACKUP
dryRun
“don’t touch the disk,” plan only
true/false

Note: there are no “tricky” things here like “traversal depth,” “masks,” or “regex exclusions.” This is intentional: today we’re building a prototype that’s easy to understand and not scary to run.

4. Modes: COPY vs MOVE — one word, very different risk

From the outside, COPY and MOVE seem almost the same: “in one case we copy, in the other we move.” But in terms of risk, they differ like “make a copy of a document” and “burn the original after copying.”

In COPY mode, source files remain in place. If you messed up the extension filter or the target structure, it’s annoying but usually reversible: you can delete the target folder and try again.

In MOVE mode, you physically move the files. A configuration mistake can be more costly here. That’s why the mode must be explicit and type-safe, not “true means move, false means copy.” Two days later you’ll forget what true means, and a week later you’ll forget it’s even your code.

Let’s make an enum class:

enum class Mode { COPY, MOVE }

That’s it: the compiler will no longer let you “accidentally pass 3 instead of a mode.”

5. Conflict policy: what to do if the file already exists

The second big ambiguity area is name conflicts. Imagine you want to put report.pdf into the target folder, but there’s already a report.pdf there. What do you do?

If the program “just overwrites,” it can be a disaster. If the program “always skips,” you might not notice that some files weren’t processed at all. So the conflict policy must be part of the contract, not a random if somewhere in the middle of the code.

Two simple options are enough for us:

  • SKIP: if the target file exists, do nothing; record it as skipped.
  • BACKUP: if the target file exists, first try to create a backup copy, then continue.

Again, we use an enum class to avoid “magic strings”:

enum class ConflictPolicy { SKIP, BACKUP }

6. One configuration object: less chaos in signatures

When you write functions at the level of “collect candidates,” “build plan,” “execute plan,” it’s very easy to end up with signatures like:

fun execute(sourceDir: String, targetDir: String, mode: String, dryRun: Boolean, allowedExt: Set<String>, ...)

The signature becomes longer than your sleep on a weekday. And you also start mixing up argument order.

So let’s agree: configuration is one object. The most convenient option is a data class. One more nuance: you can store paths as String, but Path provides a more “honest” type. We’ve worked with paths before and know that string concatenation of paths is a ticket to the world of weird bugs. So in the configuration we’ll store Path, not String.

import java.nio.file.Path

data class OrganizerConfig(
    val sourceDir: Path,
    val targetDir: Path,
    val mode: Mode,
    val allowedExt: Set<String>,
    val conflictPolicy: ConflictPolicy,
    val dryRun: Boolean
)

Note: allowedExt is a Set<String>, not a List<String>. What matters is checking “is this extension allowed” (set membership), not order. Also, a set automatically removes duplicates: if the user enters jpg,jpg,JPG, after normalization it all turns into a single jpg.

7. Normalizing extension and mode input

User input is a creative thing. Sometimes too creative. So in the contract it’s important to separate two stages:

  1. Raw strings — what was typed.
  2. Normalized values — what the program actually works with.

Normalization is about trim(), lowercase(), and removing the dot from an extension. Let’s make small helper functions. They’re short, but they greatly reduce the chance you’ll spend an hour later wondering why JPG doesn’t pass the filter.

fun normalizeExt(raw: String): String =
    raw.trim()
        .removePrefix(".")
        .lowercase()

Now let’s parse the mode. We want to support inputs like copy, COPY, move .

fun parseModeOrNull(raw: String): Mode? = when (raw.trim().lowercase()) {
    "copy" -> Mode.COPY
    "move" -> Mode.MOVE
    else -> null
}

And the same for conflict policy:

fun parseConflictPolicyOrNull(raw: String): ConflictPolicy? = when (raw.trim().lowercase()) {
    "skip" -> ConflictPolicy.SKIP
    "backup" -> ConflictPolicy.BACKUP
    else -> null
}

Notice the OrNull suffix: that’s your own mini-contract. It immediately says, “this function may fail to parse.” And you don’t have to crash with an exception at parsing time—sometimes it’s more convenient to return null, and then in main explain to the user what exactly they entered incorrectly. Today we’re not building a full CLI parser, but it’s a useful habit to practice “parse gently, validate strictly.”

8. Fail-fast configuration validation: better to stop before bulk operations

The most dangerous mistake in utilities that touch the file system is to start doing something and only then “suddenly realize” the parameters were invalid. For example, sourceDir doesn’t exist, or targetDir points to a file, or targetDir is inside sourceDir and you start organizing the results of organizing recursively.

So we need a validateConfig(cfg) function that performs quick checks and, if something is wrong, immediately stops execution. In Kotlin, preconditions require() and check() are perfect for this: they throw meaningful exception types and are designed for exactly these situations.

Let’s do basic validation:

import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.io.path.isDirectory

fun validateConfig(cfg: OrganizerConfig) {
    require(cfg.sourceDir.exists() && cfg.sourceDir.isDirectory()) {
        "Source directory must exist and be a directory: ${cfg.sourceDir}"
    }
    require(!cfg.targetDir.exists() || cfg.targetDir.isDirectory()) {
        "Target must be a directory (or not exist yet): ${cfg.targetDir}"
    }
}

Notice the message style: we write it so the user sees the exact path that caused the problem. A message like “invalid argument” is useless in a file utility because there are many arguments, and disk errors are mysterious even to adults.

Validation: “target is not inside source”

This check is not mandatory for a minimal prototype, but it saves you from trouble so often that it’s better to add it right away. Otherwise you can get infinite growth in the number of candidate files, because you create new files in the folder you’re traversing yourself.

import kotlin.io.path.normalize
import kotlin.io.path.startsWith

fun validateDirsNotNested(cfg: OrganizerConfig) {
    val src = cfg.sourceDir.normalize()
    val dst = cfg.targetDir.normalize()

    require(!dst.startsWith(src)) {
        "Target directory must NOT be inside source directory. source=$src, target=$dst"
    }
}

This already looks like a real contract: we’re not just checking that folders exist—we’re checking that the scenario makes sense and doesn’t create a trap.

9. Result model for reporting

At this step we’re not copying or moving yet, but it’s useful for the contract to define in advance what we want as output. The reason is simple: once you start writing execution code, you’ll start making decisions. If the result model isn’t defined, the decisions will be random, and the report will be “well… something happened.”

At a minimum we need a status and fields that can be shown to the user. For example: from where, to where, what happened, and if there was an error—what it was.

enum class OpStatus { PLANNED, COPIED, MOVED, SKIPPED, FAILED }

data class OpResult(
    val source: String,
    val target: String,
    val status: OpStatus,
    val message: String? = null
)

Why do we have PLANNED here? Because we’ll have dryRun: a mode where we want to produce “as-if results” but change nothing. From the report’s perspective, this is not “copy succeeded,” it’s “a plan.”

In a real project you could debate whether dry-run needs its own status. But for an educational prototype it’s convenient: you’ll see the difference in the report with your own eyes and won’t confuse “we actually copied” with “we only estimated.”

10. Minimal main skeleton

Now we’ll assemble a minimal main that:

  1. reads values from the console,
  2. parses and normalizes,
  3. builds OrganizerConfig,
  4. validates,
  5. prints “what we are going to do.”

We’re not doing directory traversal and operations yet—and that’s on purpose. Our goal is to feel that the contract already works and filters out invalid runs.

import java.nio.file.Path
import kotlin.io.path.Path

fun main() {
    print("Source dir: ")
    val sourceRaw = readln()

    print("Target dir: ")
    val targetRaw = readln()

    print("Mode (copy/move): ")
    val mode = parseModeOrNull(readln()) ?: return

    val cfg = OrganizerConfig(
        sourceDir = Path(sourceRaw.trim()),
        targetDir = Path(targetRaw.trim()),
        mode = mode,
        allowedExt = setOf("jpg", "png", "pdf"), // for now we hardcode it
        conflictPolicy = ConflictPolicy.SKIP,
        dryRun = true
    )

    validateConfig(cfg)
    validateDirsNotNested(cfg)

    println("Config OK. dryRun=${cfg.dryRun}, mode=${cfg.mode}")
}

Yes, there is “hardcode it.” That’s fine at this step: we’re learning the shape, not building the final input UX. In the next project steps we can extend the config, but the foundation is already laid: a configuration object, normalization, and fail-fast validation.

11. Useful nuances

How the contract fits into the utility pipeline

When you start writing File Organizer end-to-end, it helps to keep a simple pipeline in mind. The contract is its input and rules, and everything else is processing stages.

flowchart LR
    A[Raw input strings] --> B[Normalization and parsing]
    B --> C[OrganizerConfig]
    C --> D[validateConfig: fail-fast]
    D --> E[Next: collect candidates and build a plan]

This seems obvious until you write a utility “straight in a loop”: then validation usually gets smeared across the code and stops being a guarantee.

Where to keep the code

When a project grows, you start searching for functions. And if everything is in one file Main.kt, searching turns into a mini-quest. Kotlin recommends meaningful file names: if a file contains one class, it makes sense to name it after the class, and if it contains a set of related declarations—use a name that describes the contents.

For today’s prototype, you can agree on this minimal split:

  • Config.kt: OrganizerConfig, Mode, ConflictPolicy
  • Parsing.kt: parseModeOrNull, parseConflictPolicyOrNull, normalizeExt
  • Validation.kt: validateConfig, validateDirsNotNested
  • Main.kt: only assembling and running the scenario

This is not “architecture on steroids,” it’s just a way not to drown in your own code.

12. Common mistakes when designing the File Organizer contract

Mistake #1: encode COPY/MOVE mode as a Boolean.
It looks tempting: val move = true. But within a few days, this code reads like a riddle: does true mean “move” or “copy”? And if a third mode appears, Boolean stops coping entirely. enum class Mode makes the contract self-documenting.

Mistake #2: don’t fix a conflict policy and decide on the fly.
When name conflicts are handled in three different places, sooner or later you’ll get a situation where one part of the code skips, another overwrites, and a third tries to do a backup. The user will see chaos. The conflict policy must be part of OrganizerConfig so the decision is consistent.

Mistake #3: mix parsing, validation, and execution.
A very common scenario: you read a string, immediately try to create a directory, immediately traverse files, and in the process suddenly realize the mode wasn’t recognized. For bulk operations this is dangerous: you might modify the disk before you find the error. It’s better to first build OrganizerConfig, then validateConfig, and only then move on to “touching files.”

Mistake #4: weak error messages.
“Invalid config” is not help. A good message should include a specific path and a specific condition: “target must be a directory or not exist.” This is especially important because file problems often depend on the environment: permissions, current directory, OS specifics.

Mistake #5: don’t check that target is outside source.
Without this check, you can accidentally organize what’s already organized: you create the target structure inside the source, the traversal starts finding new files, and the utility does more and more “work” until you stop it manually. It’s not a “bad algorithm,” it’s simply an unfixed contract rule.

Mistake #6: store dirty extensions without normalization.
If you compare extensions as strings without lowercase() and removing the dot, then .JPG and jpg become different values, and it will seem to the user that the utility “sometimes works, sometimes doesn’t.” Normalization is cheap insurance against such bugs.

1
Task
Kotlin SELF,  46 level,  0 lecture
Locked
Normalize the extension
Normalize the extension
1
Task
Kotlin SELF,  46 level,  0 lecture
Locked
Organizer command
Organizer command
1
Task
Kotlin SELF,  46 level,  0 lecture
Locked
List of extensions
List of extensions
1
Task
Kotlin SELF,  46 level,  0 lecture
Locked
Startup configuration
Startup configuration
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet