JavaRush/Courses/Kotlin SELF/Categories, target paths, and conflicts

Categories, target paths, and conflicts

Open

1. Layout as pure logic

When you write a utility that touches files in bulk, it’s very easy to quietly mix everything into one mess: here we found a file, immediately decided the category, immediately copied it, immediately caught an error, immediately printed a report. In a small example it looks “fine,” but in real life it quickly turns into a monster.

The idea of this lecture is to separate pure computation from disk operations. Layout is exactly pure computation: we take input (a file, its extension, a relative path, rules) and produce output (a category and a target path). You can eyeball it, log it, show it to the user as a “plan,” and most importantly — you can do it without copying/moving. And we’ll leave the “operations engine” for the next lecture.

Let’s capture a small thought-formula:

Candidate file + Layout rules => Plan (where to put it and what to do on conflict)

As a diagram, the pipeline looks like this:

flowchart LR
    A[Candidate files] --> B[Category by extension]
    B --> C[Target path: target/category/relative]
    C --> D[Name conflict check]
    D --> E[PlanItem: data for execution and reporting]

2. Categories by extension

When we say “lay out files,” it usually means we want a more-or-less human structure: images separate, documents separate, audio separate. You can store categories as strings, but strings have the superpower of “making a typo without noticing” (and then wondering where the documets folder came from).

So the calmest option is enum class Category. It restricts the set of options and helps the compiler catch mistakes instead of you (and the compiler is quite a pedant, but useful in daily life).

enum class Category {
    IMAGES,
    DOCS,
    AUDIO,
    VIDEO,
    ARCHIVES,
    OTHER
}

Now we need a rule: extension -> Category. We already have extension normalization (lowercase, no dot). That means the rule will look like a small mapping table.

A small “cheat sheet” (you can expand it for yourself):

Extensions (example) Category Folder (example)
jpg, jpeg, png, gif
IMAGES
images/
txt, pdf, docx
DOCS
docs/
mp3, wav
AUDIO
audio/
mp4, mkv
VIDEO
video/
zip, 7z, rar
ARCHIVES
archives/
everything else
OTHER
other/

The code is short and reads like a “dictionary”:

fun categoryOf(ext: String): Category = when (ext) {
    "jpg", "jpeg", "png", "gif" -> Category.IMAGES
    "txt", "pdf", "doc", "docx" -> Category.DOCS
    "mp3", "wav" -> Category.AUDIO
    "mp4", "mkv" -> Category.VIDEO
    "zip", "7z", "rar" -> Category.ARCHIVES
    else -> Category.OTHER
}

Here we use when as a mapping table: it’s basically the ideal use case for it.

Why it’s better not to build the category folder via category.name.lowercase()

It seems convenient: “I’ll take Category.IMAGES, turn it into images, and done.” It does work, but there’s a nuance: an enum name is part of your code, while a folder name is part of the user interface. Sometimes you want Photos, sometimes Images, sometimes 01_images (yes, people do that, especially if they like sorting like accounting). So it’s better to move the “how to name the category folder” rule into a separate function.

fun categoryDirName(category: Category): String = when (category) {
    Category.IMAGES -> "images"
    Category.DOCS -> "docs"
    Category.AUDIO -> "audio"
    Category.VIDEO -> "video"
    Category.ARCHIVES -> "archives"
    Category.OTHER -> "other"
}

That way you’re not tied to .name, and the structure becomes “configurable” even without complex configs.

3. Building the target path

When it comes to paths, a beginner almost always writes something like:

val target = cfg.targetDir + "/" + category + "/" + relative

And somewhere Windows quietly cries, because its separator is \, and any person also cries if they accidentally got // or forgot a slash. In general, concatenating paths as strings is like fixing a bike with duct tape: sometimes it works, but it’s better not to get used to it.

In the JVM world we have Path, which can assemble paths correctly. We’ll use two main methods:

  • Path.resolve(...) — safely “append a path segment,” without manual slashes.
  • File.toPath() and Path.toFile() — conversions between File and Path.

Let’s build the target path using the formula:

targetRoot / categoryDir / relativePath

For example:

  • source: C:\in\music\rock\song.mp3
  • relative: music\rock\song.mp3 (inside source)
  • category: audio
  • target: D:\out\audio\music\rock\song.mp3

Code:

import java.nio.file.Path

fun buildTargetPath(
    targetRoot: Path,
    category: Category,
    relative: Path
): Path {
    val catDir = categoryDirName(category)
    return targetRoot
        .resolve(catDir)
        .resolve(relative)
}

Notice the style: a multi-line signature is easier to read, and it matches code formatting recommendations (in Kotlin this is indeed common).

Preserving subfolder structure

There are two extremes.

The first extreme is “everything into one category folder.” Then you end up with images/ containing tens of thousands of files, and searching for “where is my IMG_0001.jpg” turns into an archaeological expedition.

The second extreme is “fully copy the absolute path.” Then you accidentally create folders like target/C:/Users/... (or something equally weird), and that doesn’t look great either.

The golden middle is to keep the relative path inside source. We already know how to do that via relativize, and now we’ll just embed it into plan building.

Let’s recall the function (it’s short, but important):

import java.io.File
import java.nio.file.Path

fun relativePath(sourceDir: File, file: File): Path =
    sourceDir.toPath().relativize(file.toPath())

And now we can guarantee: whatever is inside sourceDir (subfolders, nesting), it will be neatly preserved in targetDir, but under the category folder.

4. Layout plan and conflicts

Now we’ve reached an important design point. We need an object that describes “what we want to do” — but does not do it yet. This is convenient for three reasons: you can print the plan, you can check it by eye, and you can execute it later either by copying or moving.

Let’s create data class PlanItem. In previous days you’ve already gotten used to data class being a handy “data box” that automatically gives you toString, comparison, and so on. (And yes, this is exactly the moment when Kotlin does part of the routine for you.)

import java.io.File
import java.nio.file.Path

data class PlanItem(
    val source: File,
    val relative: Path,
    val category: Category,
    val target: File,
    val conflict: PlannedConflict
)

Now the question: what is conflict? It’s the result of our decision “what to do if a file already exists in target.” For now we do not execute copying/backup — we only record the decision.

Let’s describe the conflict options with a separate enum (simple and clear):

enum class PlannedConflict {
    NONE,        // target does not exist
    SKIP,        // exists and policy says "skip"
    BACKUP       // exists and policy says "make a backup, then continue"
}

Why not Boolean hasConflict? Because Boolean instantly turns into the question “what does true mean?” An enum makes the meaning readable right in the code.

Conflict strategy

Name conflicts are not a “rare case,” but a normal everyday situation. The destination folder may already contain a file with the same name, especially if you run the utility again or lay out files from different sources.

At this step we must do two things:

First, explicitly choose a policy, rather than “whatever happens.” In the contract we already have ConflictPolicy (for example, SKIP or BACKUP).

Second, decide: is a conflict an error or a normal case? In our educational project we treat it as a normal case, but it must be reflected in the plan.

We need a function that, based on the policy and the fact of target existence, gives us PlannedConflict.

fun plannedConflict(
    targetExists: Boolean,
    policy: ConflictPolicy
): PlannedConflict {
    if (!targetExists) return PlannedConflict.NONE

    return when (policy) {
        ConflictPolicy.SKIP -> PlannedConflict.SKIP
        ConflictPolicy.BACKUP -> PlannedConflict.BACKUP
    }
}

Notice how it reads: “if target does not exist — no conflict; otherwise — decide by policy.” This is as straightforward as it gets, and straightforwardness is a virtue in file utilities.

Why BACKUP is only in the plan for now

Because backup is a disk operation: renaming, error checks, possibly deleting an old .bak, possibly being unable to rename due to permissions. That’s already the “operations engine” and handling partial failures — the next lecture.

Right now our task is to honestly say: “here there will be a backup, if we reach execution.”

5. Building and showing the plan

Now let’s assemble the central function: “create one plan item for one file.” This is the place where all pieces come together: extension → category → relative path → target path → conflict.

The function will be small, but useful.

import java.io.File

fun planOne(
    sourceDir: File,
    targetDir: File,
    file: File,
    cfg: OrganizerConfig
): PlanItem? {
    val ext = extensionOrNull(file) ?: return null
    if (ext !in cfg.allowedExt) return null

    val rel = relativePath(sourceDir, file)
    val category = categoryOf(ext)

    val targetPath = buildTargetPath(targetDir.toPath(), category, rel)
    val targetFile = targetPath.toFile()

    val conflict = plannedConflict(targetFile.exists(), cfg.conflictPolicy)

    return PlanItem(
        source = file,
        relative = rel,
        category = category,
        target = targetFile,
        conflict = conflict
    )
}

Several things matter here at once.

We return PlanItem? because the file might not match the rules (no extension, extension not allowed). This style works well with collection processing via mapNotNull, where null is simply dropped from the result. This is one of the standard patterns for working with collection transformations in Kotlin.

Checking targetFile.exists() is already reading file system state, but it’s safe: we don’t change anything, we only “look.” And we need it so the plan reflects reality.

Building a plan from a list of candidates

Assume we already got candidates in the previous step (for example, collectCandidates(...)). Now we can build the plan as a list transformation.

import java.io.File

fun buildPlan(
    sourceDir: File,
    targetDir: File,
    candidates: List<File>,
    cfg: OrganizerConfig
): List<PlanItem> {
    return candidates.mapNotNull { file ->
        planOne(sourceDir, targetDir, file, cfg)
    }
}

It reads almost “like English”: take the candidates, turn them into plan items, dropping the ones that don’t fit.

How to show the plan to the user

Even if you’re not writing a full report (that will be a separate topic), it’s extremely useful for the user to see at least a “draft”: what will go where, and where conflicts will be. Especially in MOVE mode, because moving psychologically feels like “I’m touching the original.” (Even if you did everything right, your brain still worries.)

Let’s make a simple formatting function, without complex tables and alignment — just a readable line.

fun describe(item: PlanItem): String {
    val base = "${item.category}: ${item.source.path} -> ${item.target.path}"
    return when (item.conflict) {
        PlannedConflict.NONE -> base
        PlannedConflict.SKIP -> "$base | conflict: SKIP (target exists)"
        PlannedConflict.BACKUP -> "$base | conflict: BACKUP (target exists)"
    }
}

And an example of printing:

fun printPlan(plan: List<PlanItem>) {
    for (item in plan) {
        println(describe(item))
        // Output example:
        // IMAGES: /in/cat.png -> /out/images/cat.png
    }
}

Yes, it’s not a “beautiful report,” but it’s already an excellent level of transparency: you can see what exactly will happen.

6. Useful nuances

OTHER is a safety net, not a trash bin

When you introduce the OTHER category, it’s easy to feel like the person who sweeps everything that didn’t fit under the rug. But OTHER is useful precisely as insurance: if you didn’t recognize an extension, the file should not “disappear” or “break the program.” It simply goes into the other/ folder, and the user can decide manually what to do with it.

In practice this means categoryOf(ext) should almost never return null. It should return OTHER if it doesn’t know the extension. Then the “plan” will be complete and the behavior predictable.

Sanity check: the target path must be inside target

Sometimes students (and not only students) accidentally build the path so the file ends up “somewhere else.” A typical reason is mixing up source/target, or calling resolve in the wrong order.

We don’t need complex checks, but one simple idea is useful: the target path should start with targetDir. For Path there’s a method startsWith(...). We won’t turn this into a huge validation system, but we can add a small safeguard in planOne.

import java.nio.file.Path

fun isInside(root: Path, child: Path): Boolean =
    child.normalize().startsWith(root.normalize())

And in planOne after targetPath:

val root = targetDir.toPath()
if (!isInside(root, targetPath)) return null

This is more of a “seat belt” than core logic. If it ever triggers, it means there’s a bug somewhere in path construction, and it’s better not to execute any operations at all.

7. Common mistakes

Mistake #1: concatenating paths as strings instead of Path.resolve(...).
At first it seems that "$target/$category/$rel" is fast and clear. Then it turns out that somewhere there’s a double slash, somewhere a separator is missing, and on Windows everything looks like a set of random characters. Using Path.resolve(...) makes path building correct on any OS and removes “slash magic.”

Mistake #2: storing the category as a string rather than an enum.
Strings give freedom… and typos. As a result you get folders like doc, docs, Documents, dcos, and the project competes with itself. enum class Category restricts the options and forces the code to be honest: either the category exists, or the compiler won’t let you build the project.

Mistake #3: the categorization function returns null, and files “fall out” of the plan with no explanation.
If you make categoryOf(ext): Category? and then somewhere write ?: return null, it’s very easy to lose files silently. It’s much calmer to return Category.OTHER and keep the file in the plan so the user sees it was handled with a fallback rule.

Mistake #4: the name conflict is resolved at execution time, and the plan is silent about it.
If the plan says “everything’s OK,” but at execution time it suddenly turns out that half the files were skipped because target files already existed — the user will suspect a bug, and not without reason. It’s better to record PlannedConflict in PlanItem right away so the plan is honest and shows problematic places in advance.

Mistake #5: trying to do backup inside the layout logic.
Layout should be a computation: determine the category and the target path. Backup is a file operation with errors, permissions, and surprises. If you mix them, the code becomes hard to test and hard to read: it’s unclear where we’re “thinking” and where we’re already “touching the disk.” Keep backup as part of execution, and in the plan keep only the decision BACKUP.

Mistake #6: computing a relative path via substring instead of relativize.
While paths are simple, it seems like “I’ll cut off the start of the string and I’m done.” But as soon as you get different separators, different absolute path variants, symbolic links, and other “joys,” string logic starts to break. Path.relativize(...) solves the task as a path problem, not a string problem — and that is much more reliable.

1
Task
Kotlin SELF,  46 level,  2 lecture
Locked
Type-based sorting
Type-based sorting
1
Task
Kotlin SELF,  46 level,  2 lecture
Locked
Category folder
Category folder
1
Task
Kotlin SELF,  46 level,  2 lecture
Locked
Target path
Target path
1
Task
Kotlin SELF,  46 level,  2 lecture
Locked
Conflict resolution
Conflict resolution
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet