JavaRush /Курси /Kotlin SELF /Проєкт File Organizer

Проєкт File Organizer

Kotlin SELF
Рівень 46 , Лекція 0
Відкрита

1. Вступ

Якщо ви коли-небудь намагалися «просто написати утиліту, яка розкладе файли по теках», то знаєте, чим це зазвичай закінчується: у коді раптово зʼявляється 12 параметрів, 7 булевих прапорців і магічний рядок "backup2_final_final". Контракт — це спосіб заздалегідь домовитися, що саме робить програма і як вона має поводитися у спірних ситуаціях, аби потім не сперечатися із самим собою, дивлячись на if (flag) { ... } else { ... }.

У цій лекції ми зафіксуємо контракт File Organizer: які вхідні параметри він приймає, які режими підтримує, що робить у разі конфлікту імен і що ми вважаємо коректною роботою. На наступних кроках проєкту ми вже будемо обходити каталоги, будувати план розкладання та виконувати операції. Але без контракту це буде схоже на ремонт квартири без проєкту: «тут буде гарно, а чому дріт зі стіни стирчить — потім розберемося».

2. Що таке контракт для консольної утиліти

Контракт утиліти — це не юридичний документ, а практична специфікація. Він відповідає на запитання: що на вході, що на виході, які правила, які гарантії, які обмеження. Якщо ви проговорили це у вигляді типів і структур даних, компілятор Kotlin стає вашим союзником: він починає не просто «збирати програму», а допомагати вам не робити дурниць.

Для File Organizer контракт зручно сприймати як три шари:

  1. Конфігурація — що користувач передав утиліті: звідки читати, куди складати, як діяти.
  2. Політики — як розвʼязуємо неоднозначності: що робити за конфліктів, як ставитися до файлів без розширення, що вважати помилкою.
  3. Критерії коректності — що вважаємо успішною роботою, які результати фіксуємо і який вигляд мають статуси.

У цій лекції ми зосередимося на першому й другому шарах і трохи торкнемося третього, щоб підготувати ґрунт для звітності та безпечних масових операцій.

3. Мінімальний набір вхідних параметрів

Перш ніж малювати класи та enum, корисно по-людськи виписати, які параметри утиліті потрібні, щоб вона працювала передбачувано. Важливо не перегнути: що більше параметрів, то більше способів запустити програму неправильно. Але й надто малий набір — і поведінка стає «магічною», а магія в I/O зазвичай закінчується драмою.

Нам достатньо такого набору:

Параметр Сенс Приклад
sourceDir
вихідний каталог, де шукаємо файли
./downloads
targetDir
каталог призначення (корінь)
./organized
mode
копіювати чи переміщати
COPY / MOVE
allowedExt
які розширення нас цікавлять
{"jpg","png","pdf"}
conflictPolicy
що робити, якщо цільовий файл уже існує
SKIP / BACKUP
dryRun
«не чіпати диск», лише планування
true/false

Зверніть увагу: тут немає «хитрих» речей на кшталт «глибини обходу», «масок», «винятків за 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. Нормалізація введення розширень і режимів

Користувацьке введення — річ творча. Іноді навіть надто творча. Тому в контракті важливо розділити дві стадії:

  1. Сирі рядки — те, що ввели.
  2. Нормалізовані значення — те, з чим реально працює програма.

Нормалізація — це про 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()) {
        "Вихідний каталог має існувати та бути каталогом: ${cfg.sourceDir}"
    }
    require(!cfg.targetDir.exists() || cfg.targetDir.isDirectory()) {
        "Ціль має бути каталогом (або ще не існувати): ${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)) {
        "Цільовий каталог НЕ має бути всередині вихідного каталогу. 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, який:

  1. читає значення з консолі,
  2. парсить і нормалізує,
  3. будує OrganizerConfig,
  4. валідовує,
  5. друкує «що ми збираємося зробити».

Ми поки що не робимо обхід каталогів і операцій — спеціально. Наша мета — відчути, що контракт уже працює й відсікає неправильні запуски.

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

fun main() {
    print("Вихідний каталог: ")
    val sourceRaw = readln()

    print("Цільовий каталог: ")
    val targetRaw = readln()

    print("Режим (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("Конфігурацію перевірено. 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 стануть різними значеннями, і користувачу здаватиметься, що утиліта «іноді працює, іноді ні». Нормалізація — дешева страховка від таких багів.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ