1. Введение
Если вы когда-нибудь пытались «просто написать утилиту, которая разложит файлы по папкам», то знаете, как это обычно заканчивается: в коде внезапно появляется 12 параметров, 7 булевых флагов и волшебная строка "backup2_final_final". Контракт — это способ заранее договориться, что именно делает программа и как она должна вести себя в спорных ситуациях, чтобы потом не спорить с самим собой, глядя на if (flag) { ... } else { ... }.
В этой лекции мы зафиксируем контракт File Organizer: какие входные параметры он принимает, какие режимы поддерживает, что делает при конфликте имён, и что считаем корректной работой. В следующих шагах проекта мы уже будем обходить директории, строить план раскладки и выполнять операции. Но без контракта это будет похоже на ремонт квартиры без проекта: «тут будет красиво, а почему провод из стены торчит — это потом разберёмся».
2. Что такое контракт для консольной утилиты
Контракт утилиты — это не юридический документ, а практическая спецификация. Он отвечает на вопросы: что на входе, что на выходе, какие правила, какие гарантии, какие ограничения. Если вы это проговорили в виде типов и структур данных, то компилятор Kotlin становится вашим союзником: он начинает не просто «собирать программу», а помогать вам не делать глупости.
Для File Organizer контракт удобно воспринимать как три слоя:
- Конфигурация — что пользователь передал утилите: откуда читать, куда класть, как действовать.
- Политики — как решаем неоднозначности: что делать при конфликтах, как относиться к файлам без расширения, что считать ошибкой.
- Критерии корректности — что считаем успешной работой, какие результаты фиксируем, и как выглядят статусы.
В этой лекции мы сосредоточимся на первом и втором, и чуть-чуть на третьем, чтобы подготовить почву для отчётности и безопасных массовых операций.
3. Минимальный набор входных параметров
Прежде чем рисовать классы и enum, полезно по-человечески выписать: какие параметры утилите нужны, чтобы она работала предсказуемо. Важно не перегнуть: чем больше параметров, тем больше способов запустить программу неправильно. Но слишком мало — и поведение становится «волшебным», а магия в I/O обычно заканчивается драмой.
Нам достаточно такого набора:
| Параметр | Смысл | Пример |
|---|---|---|
|
исходная директория, где ищем файлы | |
|
директория назначения (корень) | |
|
копировать или перемещать | |
|
какие расширения нас интересуют | |
|
что делать, если целевой файл уже существует | |
|
«не трогать диск», только планирование | |
Обратите внимание: здесь нет «хитрых» вещей вроде «глубины обхода», «масок», «исключений по regex». Это осознанно: сегодня мы строим прототип, который можно понять и не бояться запускать.
4. Режимы: COPY vs MOVE — одно слово, а риск разный
Снаружи кажется, что COPY и MOVE — это почти одно и то же: «в одном случае копируем, в другом переносим». Но по рискам они различаются так же, как «сделать копию документа» и «сжечь оригинал после копирования».
В режиме COPY исходные файлы остаются на месте. Если вы ошиблись с фильтром расширений или целевой структурой — неприятно, но обычно обратимо: можно удалить целевую папку и попробовать снова.
В режиме MOVE вы физически перемещаете файлы. Ошибка в конфигурации здесь может стоить дороже. Поэтому режим нужно делать явным и типобезопасным, а не «true значит move, false значит copy». Через два дня вы забудете, что значит true, а через неделю забудете вообще, что это ваш код.
Сделаем enum class:
enum class Mode { COPY, MOVE }
И всё: компилятор больше не даст вам «случайно передать 3 вместо режима».
5. Политика конфликтов: что делать, если файл уже существует
Вторая большая зона неоднозначности — конфликт имён. Представьте: вы хотите положить report.pdf в целевую папку, а там уже есть report.pdf. Что делать?
Если программа «просто перезапишет» — это может быть катастрофой. Если программа «всегда пропускает» — вы можете не заметить, что часть файлов вообще не обработалась. Поэтому политика конфликтов должна быть частью контракта, а не случайным if где-то в середине кода.
Нам достаточно двух простых вариантов:
- SKIP: если целевой файл существует — ничего не делаем, фиксируем как пропуск.
- BACKUP: если целевой файл существует — сначала пытаемся сделать резервную копию, а потом продолжить.
Снова используем enum class, чтобы не было «магических строк»:
enum class ConflictPolicy { SKIP, BACKUP }
6. Конфигурация одним объектом: меньше хаоса в сигнатурах
Когда вы пишете функции уровня «собери кандидатов», «построй план», «выполни план», очень легко прийти к сигнатурам вида:
fun execute(sourceDir: String, targetDir: String, mode: String, dryRun: Boolean, allowedExt: Set<String>, ...)
Сигнатура становится длиннее, чем ваш сон в будний день. А ещё вы начинаете путать порядок аргументов.
Поэтому договоримся: конфигурация — это один объект. Удобнее всего — data class. И ещё небольшой нюанс: хранить пути как String можно, но Path даёт более «честный» тип. Мы уже работали с путями и знаем, что строковая конкатенация путей — это билет в мир странных багов. Поэтому в конфигурации будем хранить Path, а не 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
)
Обратите внимание: allowedExt — это Set<String>, а не List<String>. Нам важна проверка «разрешено ли расширение» (принадлежность множеству), а не порядок. И ещё — множество автоматически убирает дубликаты: если пользователь введёт jpg,jpg,JPG, после нормализации это всё превратится в один jpg.
7. Нормализация ввода расширений и режимов
Пользовательский ввод — вещь творческая. Иногда слишком творческая. Поэтому в контракте важно разделить две стадии:
- Сырые строки — то, что ввели.
- Нормализованные значения — то, с чем реально работает программа.
Нормализация — это про trim(), lowercase(), удаление точки у расширения. Сделаем маленькие функции-помощники. Они короткие, но сильно снижают шанс, что вы потом будете час искать, почему JPG не проходит фильтр.
fun normalizeExt(raw: String): String =
raw.trim()
.removePrefix(".")
.lowercase()
Теперь разберём режим. Мы хотим поддержать ввод вроде copy, COPY, move .
fun parseModeOrNull(raw: String): Mode? = when (raw.trim().lowercase()) {
"copy" -> Mode.COPY
"move" -> Mode.MOVE
else -> null
}
И так же — политику конфликтов:
fun parseConflictPolicyOrNull(raw: String): ConflictPolicy? = when (raw.trim().lowercase()) {
"skip" -> ConflictPolicy.SKIP
"backup" -> ConflictPolicy.BACKUP
else -> null
}
Обратите внимание на суффикс OrNull: это ваш собственный мини-контракт. Он сразу говорит: «функция может не распарсить». И вы не обязаны падать исключением на этапе парсинга — иногда удобнее вернуть null, а уже в main объяснить пользователю, что именно он ввёл не так. Сегодня мы не строим полноценный CLI-парсер, но привычку «парсить мягко, валидировать строго» полезно тренировать.
8. Fail-fast валидация конфигурации: лучше остановиться до массовых операций
Самая опасная ошибка в утилитах, которые трогают файловую систему, — начать что-то делать, а потом «вдруг понять», что параметры были неверные. Например, sourceDir не существует, или targetDir указывает на файл, или targetDir находится внутри sourceDir, и вы начинаете организовывать результаты организации рекурсивно.
Поэтому нам нужна функция validateConfig(cfg), которая делает короткие проверки и, если что-то не так, сразу останавливает выполнение. В Kotlin для этого идеально подходят предусловия require() и check(): они выбрасывают исключения осмысленных типов и предназначены именно для таких ситуаций.
Сделаем базовую валидацию:
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}"
}
}
Заметьте стиль сообщений: мы пишем так, чтобы пользователь увидел конкретный путь, который вызвал проблему. Сообщение вида «invalid argument» в файловой утилите бесполезно, потому что аргументов много, а ошибки диска — загадочны даже для взрослых.
Валидация «target не внутри source»
Эта проверка не обязательна для минимального прототипа, но она настолько часто спасает от беды, что лучше её добавить сразу. Иначе можно получить бесконечный рост количества файлов-кандидатов, потому что вы создаёте новые файлы в папке, которую сами же обходите.
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"
}
}
Это уже похоже на настоящий контракт: мы не просто проверяем, что папки существуют, мы проверяем, что сценарий вообще имеет смысл и не создаёт ловушку.
9. Модель результата для отчёта
На этом шаге мы ещё не выполняем копирование или перемещение, но контракту полезно заранее определить: что мы хотим получить на выходе. Причина проста: как только вы начнёте писать код выполнения, вы начнёте принимать решения. Если модель результата не определена — решения будут случайными, а отчёт получится «ну… вроде что-то произошло».
Минимально нам нужен статус и поля, которые можно показать пользователю. Например: откуда, куда, что произошло, и если ошибка — какая.
enum class OpStatus { PLANNED, COPIED, MOVED, SKIPPED, FAILED }
data class OpResult(
val source: String,
val target: String,
val status: OpStatus,
val message: String? = null
)
Почему здесь есть PLANNED? Потому что у нас будет dryRun: режим, где мы хотим сформировать «как бы результаты», но ничего не менять. С точки зрения отчёта это не «успех копирования», а «план».
В реальном проекте можно спорить, нужен ли отдельный статус для dry-run. Но для учебного прототипа это удобно: вы увидите разницу в отчёте глазами и не перепутаете «мы реально скопировали» и «мы только прикинули».
10. Минимальный каркас main
Сейчас мы соберём минимальный main, который:
- читает значения из консоли,
- парсит и нормализует,
- строит OrganizerConfig,
- валидирует,
- печатает «что мы собираемся сделать».
Мы пока не делаем обход директорий и операции — это специально. Наша цель: почувствовать, что контракт уже работает и отсекает неправильные запуски.
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"), // пока захардкодим
conflictPolicy = ConflictPolicy.SKIP,
dryRun = true
)
validateConfig(cfg)
validateDirsNotNested(cfg)
println("Config OK. dryRun=${cfg.dryRun}, mode=${cfg.mode}")
}
Да, тут есть «захардкодим». Это нормально на этом шаге: мы учимся форме, а не строим финальный UX ввода. В следующих шагах проекта можно будет расширить конфиг, но фундамент уже заложен: объект конфигурации, нормализация и fail-fast валидация.
11. Полезные нюансы
Как контракт вписывается в конвейер утилиты
Когда вы начнёте писать File Organizer целиком, удобно держать в голове простой конвейер. Контракт — это его вход и правила, а всё остальное — этапы обработки.
flowchart LR
A[Сырые строки ввода] --> B[Нормализация и парсинг]
B --> C[OrganizerConfig]
C --> D[validateConfig: fail-fast]
D --> E[Дальше: сбор кандидатов и план]
Это кажется очевидным, пока вы не напишете утилиту «сразу в цикле»: там валидация обычно размазывается по коду и перестаёт быть гарантией.
Где лучше хранить код
Когда проект растёт, вы начинаете искать функции. И если всё лежит в одном файле Main.kt, то поиск превращается в мини-квест. Kotlin рекомендует осмысленные имена файлов: если файл содержит один класс, логично назвать его именем класса, а если содержит набор связанных деклараций — названием, которое описывает содержимое.
Для сегодняшнего прототипа можно договориться о таком минимальном разбиении:
- Config.kt: OrganizerConfig, Mode, ConflictPolicy
- Parsing.kt: parseModeOrNull, parseConflictPolicyOrNull, normalizeExt
- Validation.kt: validateConfig, validateDirsNotNested
- Main.kt: только сборка и запуск сценария
Это не «архитектура на стероидах», это просто способ не утонуть в собственном коде.
12. Типичные ошибки при проектировании контракта File Organizer
Ошибка №1: кодировать режим COPY/MOVE через Boolean.
Это выглядит заманчиво: val move = true. Но уже через несколько дней такой код читается как ребус: true — это «перемещать» или «копировать»? А если появится третий режим, Boolean вообще перестанет справляться. enum class Mode делает контракт самодокументируемым.
Ошибка №2: не фиксировать политику конфликтов, а решать по месту.
Когда конфликт имён обрабатывается в трёх разных местах, вы рано или поздно получите ситуацию: один участок кода пропускает, другой перезаписывает, третий пытается сделать backup. Пользователь увидит хаос. Политика конфликтов должна быть частью OrganizerConfig, чтобы решение было единым.
Ошибка №3: смешивать парсинг, валидацию и выполнение.
Очень частый сценарий: вы читаете строку, тут же пытаетесь создать директорию, тут же обходите файлы и в процессе вдруг понимаете, что режим не распознан. Для массовых операций это опасно: можно успеть изменить диск до того, как найдёте ошибку. Правильнее сначала построить OrganizerConfig, затем validateConfig, и только потом переходить к «троганию файлов».
Ошибка №4: слабые сообщения об ошибках.
Сообщение «Invalid config» — это не помощь. Хорошее сообщение должно включать конкретный путь и конкретное условие: «target должен быть директорией или не существовать». Это особенно важно, потому что файловые проблемы часто зависят от среды: прав доступа, текущей директории, особенностей ОС.
Ошибка №5: не проверять, что target находится вне source.
Без этой проверки можно случайно организовывать уже организованное: вы создаёте целевую структуру внутри исходной, обход начинает находить новые файлы, и утилита делает всё больше «работы», пока вы не остановите её вручную. Это не «плохой алгоритм», это просто незафиксированное правило контракта.
Ошибка №6: хранить грязные расширения без нормализации.
Если вы сравниваете расширения как строки без lowercase() и удаления точки, то .JPG и jpg станут разными значениями, и пользователю будет казаться, что утилита «иногда работает, иногда нет». Нормализация — дешёвая страховка от таких багов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ