1. Introduction
When a utility works with files, it always resembles a robot vacuum a little bit: the idea is great, but sometimes it finds a sock, decides it’s trash, and drives off with it into the unknown. Bulk copy/move operations are an especially dangerous area: you’re processing not one file but hundreds/thousands, and any error turns from “oops” into “oh no, where are my 10 years of photos?”.
In this lecture we’ll add three things that dramatically increase the chances that the user remains your friend (and not the person who texts you at 3 a.m., “give me my files back”).
We’ll learn to run the utility in dry-run mode (build a plan and “sort of” execute it without changing the file system), write a operations log as simple lines to a file (so it’s clear what happened), and generate a final report so that it is written atomically—that is, it doesn’t leave a “half-file” if there’s a power loss, disk error, or permissions issue.
2. Dry-run: a rehearsal without file system changes
Dry-run sounds like “a dry start,” but in essence it’s “a rehearsal before the concert.” In this mode the utility should go through all the intellectual steps: read the configuration, collect candidates, build a placement plan, check conflicts, and… not perform real copyTo() and renameTo().
It’s important to grasp the philosophy: dry-run is not “we’ll do part of it and skip part of it.” It must be completely free of file system changes. The most that is acceptable is writing a log and a report (and even then, usually logs/reports are written to a separate folder so they don’t interfere with the target directory).
To make dry-run look like a “mode switch” in code, it’s convenient to create a wrapper function that either calls the real execution or “simulates” results based on the plan.
Here’s a minimal version—we’ll return a list of OpResult, but with the status “didn’t touch it because dry-run”:
fun executeWithDryRun(plan: List<PlanItem>, cfg: OrganizerConfig): List<OpResult> {
if (!cfg.dryRun) return executePlan(plan, cfg)
return plan.map { item ->
OpResult(
source = item.source.path,
target = item.target.path,
status = Status.SKIPPED,
error = "dry-run"
)
}
}
Notice a small detail: we don’t invent a new entity like “DryRunResult”; instead we use the already existing OpResult. Yes, the SKIPPED status isn’t philosophically perfect here, but it’s practical: the report will show that operations were not executed, and why.
If you want a more “honest” model, you can add a new status PLANNED, but that’s already a change to the results contract. For today, we keep the model simple: status + explanation.
3. Operations journal: a log that saves your nervous system
A journal is not the same as a report. A report is the outcome (how many files were processed, what errors happened). A journal is “what happened along the way”: start, parameters, how many candidates were found, how many plans were built, where the report is located, and why we stopped—if we stopped.
A journal is especially useful in two cases: when a user says “it doesn’t work,” and when you yourself open your code a week later and think, “what did I mean?” A programmer without logs is like a detective without a notebook: seems smart, but the evidence disappears somewhere.
We won’t build a “real” logging system with levels and rotation (that’s a separate topic). We’ll make the simplest possible protocol: each line is one event. And we’ll add a simple timestamp to the line so it’s clear in what order everything happened.
For time we’ll use the Java class LocalDateTime (it’s available on the JVM, and that’s exactly where we are right now).
import java.io.File
import java.time.LocalDateTime
fun appendLogLine(logFile: File, message: String) {
logFile.parentFile?.mkdirs()
val ts = LocalDateTime.now()
logFile.appendText("[$ts] $message\n")
}
Notice: the function is small, but it does something important. It creates the directory for the log itself (if needed), adds a newline, and guarantees a consistent format.
Now in the “main flow” you can write like this (without a full main yet—just the idea):
import java.io.File
fun logStart(logFile: File, cfg: OrganizerConfig) {
appendLogLine(logFile, "Start File Organizer")
appendLogLine(logFile, "source=${cfg.sourceDir}, target=${cfg.targetDir}")
appendLogLine(logFile, "mode=${cfg.mode}, dryRun=${cfg.dryRun}")
}
It looks banal, but in practice such a journal very quickly stops being “extra” and becomes “thank goodness it exists.”
A small table so you don’t mix up the roles (it really helps your brain):
| Artifact | For whom | When it’s read | What’s inside |
|---|---|---|---|
| Journal (log) | for the developer and diagnostics | when something went wrong | step-by-step events, parameters, hints |
| Report | for the user and the result | after execution | summary and list of operations |
| Console output | for “let me quickly check” | during the run | short messages, minimal details |
4. Report: collect the text first, then write it
With reporting there are two typical beginner mistakes. The first is generating the text right during copying, mixing copyTo() and appendLine(...) into one mess. The second is writing the report directly to the final file and then being surprised: “why is the report cut off halfway?”
We’ll do it correctly: first we build the report string (a pure function, no file system), then we write it separately (the I/O part).
A report usually consists of two parts: a short summary and a list of operations. For the summary it’s convenient to count how many results there are of each status. groupBy fits perfectly here because it turns a list into groups by key. This is exactly the idea used in the standard library for grouping.
Minimal report rendering:
fun renderReport(cfg: OrganizerConfig, results: List<OpResult>): String {
val counts = results.groupBy { it.status }
.mapValues { (_, list) -> list.size }
val sb = StringBuilder()
sb.appendLine("File Organizer report")
sb.appendLine("mode=${cfg.mode}, dryRun=${cfg.dryRun}")
sb.appendLine("totals=$counts")
return sb.toString()
}
Not bad already, but we also want a list of operations. At the same time, it’s important not to inflate the report to the size of a novel like “War and Peace: directories edition.” We’ll write one line per result and add an error only if it exists.
fun renderResultsList(results: List<OpResult>): String {
val sb = StringBuilder()
sb.appendLine()
sb.appendLine("Operations:")
for (r in results) {
val err = r.error?.let { " | error=$it" } ?: ""
sb.appendLine("${r.status}: ${r.source} -> ${r.target}$err")
}
return sb.toString()
}
Now you can assemble the full report by “concatenating strings”—that’s fine because we do it in memory, and it’s not a massive operation measured in thousands of megabytes (and if it is—then you already know about StringBuilder and can build everything into one sb):
fun renderFullReport(cfg: OrganizerConfig, results: List<OpResult>): String {
return renderReport(cfg, results) + renderResultsList(results)
}
5. Atomic report writing: temp file and renameTo
Atomic writing sounds like something from physics, but the meaning is everyday: “either the file is entirely new, or the file is entirely old; ‘half-written’ is not allowed.”
A broken report isn’t as scary as broken user files, but it’s terribly unpleasant. Imagine: the utility processed 5000 files, 4999 are fine, and in the last second writing the report failed. The user sees emptiness and thinks nothing happened at all. So the report is also part of reliability.
The scheme is:
flowchart TD
A[Generated report text in memory] --> B[Wrote to report.tmp]
B --> C{Write successful?}
C -- no --> D[Deleted tmp, returned false]
C -- yes --> E[Replaced old report.txt]
E --> F[Renamed tmp -> report.txt]
F --> G[Done: report is complete]
Implementation “in tutorial style” (simple, but disciplined):
import java.io.File
import java.io.IOException
fun writeTextAtomically(target: File, text: String): Boolean {
val parent = target.parentFile ?: return false
if (!parent.exists() && !parent.mkdirs()) return false
val tmp = File(parent, target.name + ".tmp")
return try {
tmp.writeText(text)
if (target.exists() && !target.delete()) return false
tmp.renameTo(target)
} catch (e: IOException) {
false
} finally {
if (tmp.exists()) tmp.delete()
}
}
A couple of clarifications, because it’s easy to get lost here.
We create the directory for the report in advance. This matters more than it seems, because people often want to put the report, for example, into targetDir/reports/report.txt, and the reports folder doesn’t exist yet.
We write to .tmp. Even if the program crashes halfway through writeText, it will only ruin the temporary file, not the “main” report.
Before renameTo we delete the old report if it existed. This is a “pragmatic” approach—not ideal for all OSes—but within the scope of the course and a console utility, it’s sufficient.
In finally we try to clean up tmp. Even if everything succeeds, tmp has already been renamed, exists() will be false, and we won’t delete anything. If something fails, we at least try not to leave junk behind.
6. One workflow: dry-run, log, and report
Now we have all the “building blocks,” and all that’s left is to assemble them into a single clear execution flow. It’s important to preserve the style: “plan first, then execute, then report.”
Let’s make a function runOrganizerOnce that takes the config, produces results, writes the log, and saves the report:
import java.io.File
fun runOrganizerOnce(cfg: OrganizerConfig, logFile: File, reportFile: File): Boolean {
appendLogLine(logFile, "Run started")
validateConfig(cfg)
val plan = buildPlan(cfg) // assume you already have this function from previous lectures
appendLogLine(logFile, "Plan size=${plan.size}")
val results = executeWithDryRun(plan, cfg)
appendLogLine(logFile, "Results size=${results.size}")
val reportText = renderFullReport(cfg, results)
val ok = writeTextAtomically(reportFile, reportText)
appendLogLine(logFile, "Report written=$ok path=${reportFile.path}")
return ok
}
Here I intentionally use buildPlan(cfg) “as if it already exists.” In your code, it will be a composition of previous steps: traversing directories, filtering, building PlanItem. The point is that in this lecture we don’t go back to candidate collection and layout—we add a safety and reporting layer on top of an already working engine.
And here a nice architectural idea shows up: if dry-run is just “another execution branch,” then all the rest of the code stays the same. We don’t build a second File Organizer “for rehearsals.” We have the same organizer, just in different modes.
Dry-run and name conflicts
There’s a subtle point that will definitely come up in a real project: “how should dry-run behave on name conflicts if the target folder already contains files?”
The correct answer: dry-run must be able to report what will be done. That is, if ConflictPolicy.SKIP, in dry-run it makes sense to show that the file will be skipped because the target already exists. If ConflictPolicy.BACKUP, it makes sense to show that a backup is planned. But you must not actually do renameTo on an existing file, because that’s already a file system change.
In our simplified approach (status SKIPPED + "dry-run") we don’t distinguish reasons. That’s fine for a learning step, but if you want a slightly more honest simulation, you can “inside dry-run” check target.exists() and write a more specific message into error without changing the file system:
fun simulateDryRun(item: PlanItem): OpResult {
val reason = if (item.target.exists()) "dry-run (target exists)" else "dry-run"
return OpResult(item.source.path, item.target.path, Status.SKIPPED, reason)
}
The key point: any exists() checks are safe. Any copyTo, renameTo, delete are forbidden in dry-run.
7. Common mistakes
Mistake №1: a dry-run that still “moves a little bit.”
Sometimes dry-run is implemented like this: “well, we’ll just create folders via mkdirs(), that’s not scary.” And that’s where the slippery slope begins: today you created folders, tomorrow you deleted the old report, the day after tomorrow you “accidentally” made a backup. As a result, the user runs dry-run and discovers changes. In good design, dry-run doesn’t change the file system at all—otherwise trust in the mode disappears.
Mistake №2: mixing execution and report generation into one huge loop.
Beginners often write it like: “copy the file → immediately write a line into the report,” and all of this in a single for. This is awkward to debug and almost impossible to port cleanly to dry-run. It’s much easier to keep the discipline: execution returns List<OpResult>, the report is built by a separate function from the results, and report writing is a third function.
Mistake №3: writing the report directly to the final file without a temp file.
It feels like “the report is small, what could happen to it.” But the report may be written to a network folder, the disk may be full, permissions may be restricted—and you end up with a truncated file. Temp + rename is a simple technique that dramatically improves the quality of the utility: either the report is old or new, but not “halfway.”
Mistake №4: forgetting to create the directory for the log/report.
A very common situation: you nicely specified a path like target/reports/report.txt, but the reports folder doesn’t exist. Then writeText fails, and you think the problem is in the copying code. A small parent.mkdirs() before writing saves tons of time spent on “why didn’t it write?”
Mistake №5: a log without time and context.
A log like “Start”, “Plan size=100”, “Done” is almost the same as no log at all. It has no time, no run parameters, no paths, no COPY/MOVE mode. Next time you see that file you won’t understand which run created it. Even a simple LocalDateTime.now() timestamp and a couple of config lines turns a log from “noise” into “diagnostics.”
Mistake №6: hiding report write errors and always printing “success.”
Sometimes you want to make the “user experience” smooth and print println("Done!") even if writeTextAtomically returned false. This is a bad idea: the report is part of the result. If it wasn’t written, it’s better to honestly tell the user in the console and record in the log that the report could not be saved (and where you tried to save it). Honesty here isn’t a moral category—it’s a way to reduce the number of mysterious bug reports.
GO TO FULL VERSION