JavaRush/Courses/Kotlin SELF/Bulk operations engine: copy/move, partial errors, and re...

Bulk operations engine: copy/move, partial errors, and results

Open

1. Why you need a bulk operations “engine”

Honestly, copying one file is a “mom, look, I’m a programmer” kind of task. But as soon as you have 3000 files, you suddenly find out the world is full of surprises: somewhere you don’t have permissions, somewhere the name is too long, somewhere a file with the same name already exists, somewhere the disk is full, and somewhere renameTo() just said “false” and walked away without explaining itself (like a cat you asked to take a bath).

That’s exactly why we need a bulk operations engine: a layer of code that takes a plan (a list of “what goes where”) and performs operations one item at a time, carefully accumulating results. The main principle of this lecture: an error on one file should turn into a record in the report, not the end of the program.

To keep your head organized, it helps to think of the process as a pipeline:

flowchart TD
    A[PlanItem list: source -> target] --> B[Operation on a single file]
    B --> C[OpResult: status/error]
    C --> D[List of results]
    D --> E[Report/statistics]

2. Operation results: statuses and reporting

Statuses and the result model

Before we touch anything on disk, let’s agree on what we consider a “result”. In bulk operations, the result is not only “success”, but also “skipped” and “failed”. If we reduce everything to Boolean, we lose the explanation of why a file was skipped or why it failed. And then we’ll be “healing” the program with shamanic dances and restarts.

So let’s introduce Status as an enum class and OpResult as a data model for the result. By the way, enums in Kotlin have a convenient entries collection for iterating over values (in newer Kotlin versions this is a separate feature that makes enums a bit nicer in everyday use).

A table of status meanings—so the report is easier to read:

Status What it means in our File Organizer
COPIED
The file was successfully copied to the target
MOVED
The file was successfully moved to the target
SKIPPED
We intentionally did nothing (conflict + policy
SKIP
, or another “legal” skip)
FAILED
The operation attempted to run but could not (exception,
renameTo == false
, failed to create the directory, etc.)

Mini-models:

enum class Status { COPIED, MOVED, SKIPPED, FAILED }

data class OpResult(
    val source: String,
    val target: String,
    val status: Status,
    val error: String? = null
)

Note: we store paths as String. Yes, somewhere we also have File and Path, but for a report it’s more convenient for a human to see strings. Also, a string is easier to serialize/write to a plain text log (in this lecture we only prepare the data; a nice report comes later).

Accumulating results and simple statistics

At first glance, List<OpResult> is just a normal list. In practice, it’s the most important “artifact” of the run. If you lose it or forget to populate it, you lose the ability to clearly explain to the user what happened.

The results list has two uses.

First, a human-readable report (later we’ll assemble nice text). Second, statistics: how many succeeded, how many were skipped, how many failed. And you can do this literally in one line via groupBy, because OpResult is a proper data structure.

fun countByStatus(results: List<OpResult>): Map<Status, Int> {
    return results.groupBy { it.status }
        .mapValues { (_, list) -> list.size }
}

It’s important to understand the idea here: toList() and similar operations create a “snapshot” of a collection at a point in time—this is often useful when you want to separate the original data from the result.
We won’t dive into performance or edge cases yet, but the principle “results are separate data; you can analyze them” is key.

3. Operations on a single file: copy, move, errors

Why “one file → one result” matters

From the outside, bulk processing looks like a regular for. But architecturally these are two different problems: “how to process one file” and “how to process a thousand files”. If you mix them into one giant block, chaos quickly moves in: half the checks will be about conflicts, a quarter about directories, some more about try/catch, and at the end you’ll discover you forgot to add the result to the list.

The right decomposition sounds like this: make a small function that attempts to process one file and returns an OpResult. And the loop over the whole plan will simply call it many times and collect the results.

There’s also a subtle practical benefit: you’ll be able to test and debug the logic on a single example (one file) without running the entire plan every time.

Preparing the destination directory

One of the most common beginner surprises: “I’m copying a file—why did it fail if the target path looks nice?” The answer is usually boring: because the destination folder doesn’t exist yet. You want to put the file into target/images/2026/january/photo.jpg, but the folder target/images/2026/january/ hasn’t been created.

So before any write/copy/move, we must ensure that target.parentFile exists, or try to create it via mkdirs().

Let’s make a small helper:

import java.io.File

fun ensureParentDir(target: File): Boolean {
    val parent = target.parentFile ?: return false
    return parent.exists() || parent.mkdirs()
}

Here we return Boolean because this is purely “technical preparation”: if it worked—great; if it didn’t—there’s no point trying to copy further.

Copying one file: a “narrow” try/catch

Now we’re ready for the “copy one file” operation. The key is to not try to catch absolutely everything, while also not crashing on the first problem. The compromise for a learning project is simple: catch IOException (and optionally a more general Exception as a safety net), and convert that into FAILED.

Another important nuance: copyTo() in Kotlin works with File, and it can throw exceptions. We don’t try to “defeat” them; we record them in the result.

import java.io.File
import java.io.IOException

fun copyOne(source: File, target: File): OpResult {
    return try {
        if (!ensureParentDir(target)) {
            return OpResult(source.path, target.path, Status.FAILED, "cannot create target directory")
        }

        source.copyTo(target)
        OpResult(source.path, target.path, Status.COPIED)
    } catch (e: IOException) {
        OpResult(source.path, target.path, Status.FAILED, e.message)
    }
}

Notice the style: we do an early return when there’s a directory problem. This is called a “guard clause”, and it really helps avoid nested if inside try.

Moving one file: why renameTo() can silently fail

Moving is a bit more fun, because many people expect “move” to be just “cut and paste”. In practice, people often do source.renameTo(target). And here Kotlin/Java can sometimes act like a cat: no exceptions, just false. Meaning the operation did not happen, but there is “no error”. Thanks, very informative.

So we’ll also implement moving as “one file → one result”, and we must check the Boolean:

import java.io.File

fun moveOne(source: File, target: File): OpResult {
    return try {
        if (!ensureParentDir(target)) {
            return OpResult(source.path, target.path, Status.FAILED, "cannot create target directory")
        }

        val ok = source.renameTo(target)
        if (ok) OpResult(source.path, target.path, Status.MOVED)
        else OpResult(source.path, target.path, Status.FAILED, "renameTo returned false")
    } catch (e: Exception) {
        OpResult(source.path, target.path, Status.FAILED, e.message)
    }
}

We won’t dive into platform-specific filesystem nuances yet. For a learning project, it’s enough to clearly understand this practical rule: if renameTo() returned false, it’s the same kind of error as an exception—just without a stack trace.

Where to place try/catch so processing is “resilient”

When people first run into I/O errors, they get a strong urge to do this:

try {
    // process the ENTIRE plan
} catch (e: Exception) {
    // sad println
}

The problem is that this turns one error (for example, one “broken” file) into a failure of the entire run. The user gets half the work done (or none), and minimal information.

We need a “partial errors” model: we process the list, and each file either succeeds or produces an error-result, while the rest continue processing.

That’s why try/catch must be inside the operation on a single file (copyOne/moveOne) or around the logic for one plan item—not around the entire loop. Then a “bad” file becomes one FAILED record, and we move on.

It sounds philosophical, but in practice it just means: “catch errors narrowly”.

4. Executing the plan: loop, conflicts, and wiring it up in main

executePlan(): one loop and a unified conflict protocol

Now the best part: we have PlanItem (from the previous lecture on the layout plan), we have Mode and ConflictPolicy (from the contract), and we have the copyOne/moveOne functions. All that’s left is to assemble this into one engine: executePlan().

First, a reminder of the minimal models (so the code below reads cohesively). We won’t complicate them with extra fields—we only need what’s required for execution and reporting.

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

data class PlanItem(
    val source: File,
    val relative: Path,
    val target: File
)

enum class Mode { COPY, MOVE }
enum class ConflictPolicy { SKIP, BACKUP }

Note: relative is almost not needed for execution anymore (the target is already computed), but it can be useful for reporting, so let it stay.

Now the engine itself. In this lecture, we implement only SKIP as the conflict policy (and we already discussed BACKUP earlier and can plug it in if we have a helper). The main goal is to show a structure where the conflict is handled before attempting copy/move.

fun executePlan(plan: List<PlanItem>, mode: Mode, policy: ConflictPolicy): List<OpResult> {
    val results = mutableListOf<OpResult>()

    for (item in plan) {
        if (item.target.exists() && policy == ConflictPolicy.SKIP) {
            results += OpResult(item.source.path, item.target.path, Status.SKIPPED, "target exists")
            continue
        }

        val r = when (mode) {
            Mode.COPY -> copyOne(item.source, item.target)
            Mode.MOVE -> moveOne(item.source, item.target)
        }
        results += r
    }

    return results
}

Notice how readable when is here: it literally says “depending on the mode, we copy or move”. And this is exactly the case where an enum is better than a Boolean, because with Boolean you’d have to remember what true means (copy? move? or “dry run”?), while Mode.MOVE explains itself.

Minimal wiring in main

To make everything look like a single application (and not a set of unrelated functions), it helps to imagine the final wiring in main: earlier we built the config, candidates, and plan; now we call the engine and print a short summary. Here I’ll show the “skeleton”, without details about traversal and planning (those were covered in previous lectures).

fun main() {
    val plan: List<PlanItem> = buildPlanSomehow() // from previous lectures
    val results = executePlan(plan, mode = Mode.COPY, policy = ConflictPolicy.SKIP)

    val stats = countByStatus(results)
    println("Done. Stats: $stats") // Done. Stats: {COPIED=10, SKIPPED=2, FAILED=1}
}

Yes, buildPlanSomehow() is a stub here—because today we’re learning the “engine” itself, not directory traversal or layout rules. In a real project, you’ll plug in your collectCandidates(...) and buildPlan(...) here.

5. Typical mistakes when implementing a bulk operations engine

Mistake No. 1: one global try/catch around the whole run.
When try/catch wraps the entire batch, any error turns into “everything crashed”. The user gets fewer processed files, and you get less information. It’s much more robust to catch errors at the “one file → one OpResult” level so the rest of the processing continues.

Mistake No. 2: not creating the destination directory before copying/moving.
It’s very easy to forget that you can’t put a file into a folder that doesn’t exist. If you don’t do target.parentFile.mkdirs(), copying will fail (or return an error) even with perfectly correct paths. The habit “first ensureParentDir, then the operation” makes behavior predictable.

Mistake No. 3: assuming renameTo() is reliable and not checking the result.
renameTo() can return false without an exception, and if you ignore it, you’ll get a “silent failure”: the program will say “everything is OK”, but the file will remain in place. In a bulk operations engine, false must turn into Status.FAILED with a clear message—otherwise debugging becomes a detective story.

Mistake No. 4: losing the error cause.
If in the catch you do just Status.FAILED without text, you destroy the most useful part of the result—the explanation. Even e.message (even if it’s sometimes null) helps a lot in understanding what happened: “Permission denied”, “No space left on device”, “File not found”.

Mistake No. 5: mixing plan building and executing operations in one place.
When a function “categorizes” a file and immediately copies it, you lose control: it’s harder to do a “dry run”, harder to test, harder to write a report. It’s much cleaner to keep the rule: the plan is pure computation, the engine is I/O and error handling, and the result is data for reporting.

1
Task
Kotlin SELF,  46 level,  3 lecture
Locked
Operation statuses
Operation statuses
1
Task
Kotlin SELF,  46 level,  3 lecture
Locked
Directory before writing
Directory before writing
1
Task
Kotlin SELF,  46 level,  3 lecture
Locked
Copying a File
Copying a File
1
Task
Kotlin SELF,  46 level,  3 lecture
Locked
Moving a file
Moving a file
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet