Bagian 2 ditutup dengan masalah yang khas untuk alat file: folder .git dan __pycache__ ikut ditelusuri, penghapusan selalu permanen, dan setiap perintah menangani galatnya sendiri-sendiri dengan blok if yang berulang. Pada bagian ini kita menuntaskan ketiganya. filetool memperoleh berkas konfigurasi JSON yang bisa menyesuaikan perilaku per proyek, environment variable FILETOOL_CONFIG untuk mengarahkan berkas tersebut, serta satu titik penanganan galat terpusat yang mengubah FileNotFoundError dan teman-temannya menjadi pesan yang ramah.

Berkas Konfigurasi #

Empat kunci berikut mengendalikan perilaku filetool:

json
{
  "pola_eksklusi": [".git", "__pycache__", "*.tmp", "*~", ".filetool-sampah"],
  "kedalaman_rekursif": 3,
  "gunakan_sampah": true,
  "folder_sampah": ".filetool-sampah"
}
KunciTipeNilai bawaanEfek
pola_eksklusilist[".git", "__pycache__", "*.tmp", "*~"]nama yang cocok pola fnmatch dilewati oleh cari, ukuran, dan organisasi
kedalaman_rekursifint5jumlah tingkat di bawah direktori awal yang boleh ditelusuri
gunakan_sampahboolfalsehapus memindahkan berkas ke folder sampah, bukan menghapus permanen
folder_sampahstr".filetool-sampah"nama folder sampah, dibuat di direktori kerja saat perintah berjalan

Simpan JSON di atas sebagai config.json di direktori kerja. Pola ".filetool-sampah" sengaja dimasukkan ke daftar eksklusi agar folder sampah sendiri tidak ikut terhitung pada penelusuran berikutnya.

Memuat Konfigurasi Berlapis #

Konfigurasi dimuat dalam tiga lapis: nilai bawaan di kode selalu tersedia, berkas config.json di direktori kerja menimpanya bila ada, dan environment variable FILETOOL_CONFIG mengalihkan lokasi berkas untuk skenario seperti satu berkas konfigurasi dipakai banyak proyek.

python
KONFIG_BAWAAN = {
    "pola_eksklusi": [".git", "__pycache__", "*.tmp", "*~"],
    "kedalaman_rekursif": 5,
    "gunakan_sampah": False,
    "folder_sampah": ".filetool-sampah",
}


def muat_konfig() -> dict | None:
    """Muat konfigurasi berlapis: nilai bawaan, lalu berkas JSON bila ada."""
    config = dict(KONFIG_BAWAAN)
    jalur = os.environ.get("FILETOOL_CONFIG", "config.json")
    berkas = Path(jalur)
    if not berkas.exists():
        if os.environ.get("FILETOOL_CONFIG"):
            print(f"filetool: konfigurasi tidak ditemukan: {berkas}", file=sys.stderr)
            return None
        logging.debug("tidak ada berkas konfigurasi; memakai nilai bawaan")
        return config
    try:
        with berkas.open(encoding="utf-8") as f:
            data = json.load(f)
    except PermissionError:
        print(f"filetool: izin ditolak saat membaca: {berkas}", file=sys.stderr)
        return None
    except json.JSONDecodeError as e:
        print(f"filetool: konfigurasi bukan JSON valid: {berkas} "
              f"(baris {e.lineno}, kolom {e.colno})", file=sys.stderr)
        return None
    if not isinstance(data, dict):
        print(f"filetool: akar konfigurasi harus objek: {berkas}", file=sys.stderr)
        return None
    config.update(data)
    logging.debug("konfigurasi dimuat dari %s", berkas)
    return config

Mari kita bedah baris-baris pentingnya:

  1. Baris 1 (KONFIG_BAWAAN = {): nilai bawaan hidup di kode, bukan di berkas — pengguna baru mendapatkan program yang berjalan tanpa menulis konfigurasi apa pun.
  2. Baris 11 (config = dict(KONFIG_BAWAAN)): dict(...) membuat salinan; tanpa salinan, config.update() pada baris 33 akan mengotori dictionary bawaan itu sendiri.
  3. Baris 12 (os.environ.get("FILETOOL_CONFIG", "config.json")): fungsi get() mengembalikan nilai environment variable bila diisi, jika tidak diisi dikembalikan "config.json" sebagai jalur baku.
  4. Baris 14-19 (if not berkas.exists():): berkas baku yang tidak ada adalah hal normal, jadi program lanjut dengan nilai bawaan; tetapi berkas yang ditunjuk FILETOOL_CONFIG adalah permintaan eksplisit pengguna, sehingga hilangnya berkas itu adalah galat yang layak menghentikan program dengan exit code 1.
  5. Baris 20-29 (try: dan dua except pertama): module json mem-parsing isi berkas menjadi structure Python; JSONDecodeError ditangkap terpisah agar pesan galatnya bisa menyebut posisi baris dan kolom persis lokasi kesalahan.
  6. Baris 30-32 (if not isinstance(data, dict):): JSON yang sah bisa berupa list atau string; hanya objek (dictionary Python) yang masuk akal sebagai konfigurasi, sisanya ditolak sebelum sempat mengacaukan config.update().

Penyaringan Berkas #

Pola eksklusi dibandingkan terhadap nama berkas dengan fungsi fnmatch() dari standard library. Tiga fungsi kecil membagi pekerjaan ini agar bisa diuji terpisah pada Bagian 4:

python
def parse_exclude(nilai: str | list[str] | None) -> list[str]:
    """Normalisasi pola eksklusi dari string terpisah koma atau list."""
    if not nilai:
        return []
    kandidat = nilai.split(",") if isinstance(nilai, str) else list(nilai)
    return [pola.strip() for pola in kandidat if pola.strip()]


def cocok_pola(nama: str, pola: list[str]) -> bool:
    """Cek apakah sebuah nama cocok dengan pola fnmatch mana pun."""
    return any(fnmatch.fnmatch(nama, satu_pola) for satu_pola in pola)


def filter_files(daftar: list[Path], pola: list[str]) -> list[Path]:
    """Saring path yang namanya cocok pola eksklusi mana pun."""
    return [p for p in daftar if not cocok_pola(p.name, pola)]
  1. Baris 1-6 (parse_exclude): menerima bentuk apa pun yang wajar — list dari config.json, string terpisah koma dari opsi --kecuali, atau None — lalu mengembalikan list pola yang bersih dari spasi dan elemen kosong.
  2. Baris 9-11 (cocok_pola): satu nama dinyatakan cocok bila cocok dengan salah satu pola; fnmatch(".git", ".git") bernilai True karena string identik juga pola yang sah.
  3. Baris 14-16 (filter_files): list comprehension menyaring daftar Path dan mengembalikan hanya yang lolos — fungsi murni tanpa efek samping, pasangannya diuji langsung pada Bagian 4.

Penelusuran rekursif sekarang ditangani fungsi telusuri(), pengganti rglob() polos dari Bagian 2, karena penelusuran ini harus menghormati dua aturan konfigurasi sekaligus: melewati pola eksklusi dan berhenti pada batas kedalaman.

python
def telusuri(akar: Path, config: dict) -> tuple[list[Path], list[Path]]:
    """Kumpulkan (berkas, direktori anak) secara rekursif, hormati eksklusi."""
    hasil_berkas: list[Path] = []
    hasil_direktori: list[Path] = []
    pola = parse_exclude(config.get("pola_eksklusi"))
    kedalaman = config.get("kedalaman_rekursif", 5)

    def jalan(direktori: Path, tingkat: int) -> None:
        if tingkat > kedalaman:
            logging.debug("berhenti sebelum %s (batas kedalaman %d)", direktori, kedalaman)
            return
        for entri in sorted(direktori.iterdir()):
            if cocok_pola(entri.name, pola):
                logging.debug("lewati %s (cocok pola eksklusi)", entri)
                continue
            if entri.is_dir():
                hasil_direktori.append(entri)
                jalan(entri, tingkat + 1)
            else:
                hasil_berkas.append(entri)

    jalan(akar, 1)
    return hasil_berkas, hasil_direktori
  1. Baris 8 (def jalan(...)): fungsi bersarang jalan() memanggil dirinya sendiri untuk setiap subdirektori — direktori yang cocok pola eksklusi tidak pernah dimasuki, sehingga seluruh isinya otomatis terlewati.
  2. Baris 9-11 (if tingkat > kedalaman:): parameter tingkat naik satu setiap turun satu direktori; begitu melewati kedalaman_rekursif, cabang tersebut dipangkas.
  3. Baris 13-15 (if cocok_pola(entri.name, pola):): pemeriksaan dilakukan pada nama, bukan jalur penuh, sehingga pola "*.tmp" berlaku untuk berkas sementara di tingkat mana pun.

Perintah cari, ukuran, dan organisasi memakai hasil telusuri(). Sebagai contoh, cari kini berbunyi:

python
def perintah_cari(args: argparse.Namespace, config: dict) -> int:
    akar = Path(args.path)
    pola = parse_exclude(config.get("pola_eksklusi")) + parse_exclude(args.kecuali)
    berkas_semua, _ = telusuri(akar, {**config, "pola_eksklusi": pola})
    hasil = sorted(b for b in berkas_semua if fnmatch.fnmatch(b.name, args.pola))
    if not hasil:
        print("tidak ada yang cocok.")
        return 0
    for jalur in hasil:
        print(jalur.relative_to(akar))
    return 0
  1. Baris 3 (pola = ... + parse_exclude(args.kecuali)): pola dari berkas konfigurasi digabung dengan opsi --kecuali dari command line — pola sekali pakai tidak perlu mengubah config.json.
  2. Baris 4 ({**config, "pola_eksklusi": pola}): unpacking dictionary membuat salinan config dengan kunci pola_eksklusi yang sudah digabung, tanpa mengubah dictionary aslinya.

Dengan config.json di direktori kerja, perintah yang di Bagian 2 memuntahkan sampah kini bersih:

bash
python3 main.py cari files --pola "*"
python3 main.py ukuran files

Output yang Dihasilkan:

text
arsip-lama.zip
catatan.txt
dokumen/kontrak.txt
dokumen/proposal.txt
foto/gunung.png
foto/pantai.jpg
foto-liburan.jpg
konfig-lama.json
laporan.txt
skrip-tes.py
text
total    : 1.468.086 byte (1,4 MB)
berkas   : 10
direktori: 2

Bandingkan dengan Bagian 2: draf-sementara.tmp, catatan~, .git, dan __pycache__ tidak lagi dihitung. Opsi --kecuali bekerja untuk pengecualian sekali jalan:

bash
python3 main.py cari files --pola "*" --kecuali "*.jpg, *.png"

Output yang Dihasilkan:

text
arsip-lama.zip
catatan.txt
dokumen/kontrak.txt
dokumen/proposal.txt
konfig-lama.json
laporan.txt
skrip-tes.py

Untuk mencoba batas kedalaman, kita siapkan berkas config-dangkal.json yang isinya hanya {"kedalaman_rekursif": 1} lalu mengarahkannya lewat environment variable — berkas konfigurasi baku di direktori kerja tidak tersentuh:

bash
printf '{\n  "kedalaman_rekursif": 1\n}\n' > config-dangkal.json
FILETOOL_CONFIG=config-dangkal.json python3 main.py ukuran files

Output yang Dihasilkan:

text
total    : 257.398 byte (251,4 KB)
berkas   : 6
direktori: 2

Hanya berkas pada tingkat pertama yang dihitung; isi dokumen/ dan foto/ terpangkas. Sebaliknya, jika FILETOOL_CONFIG menunjuk berkas yang tidak ada, program berhenti dengan galat yang jelas dan exit code 1:

text
filetool: konfigurasi tidak ditemukan: tidak-ada.json

hapus ke Folder Sampah #

Dengan gunakan_sampah: true pada config.json, cabang baru di perintah_hapus() mengalihkan penghapusan ke folder sampah. Nama folder diambil dari konfigurasi, dan berkas dengan nama yang sama di sampah membuat operasi dibatalkan:

python
def perintah_hapus(args: argparse.Namespace, config: dict) -> int:
    target = Path(args.path)
    if not target.is_file():
        print(f"filetool: bukan berkas: {target}", file=sys.stderr)
        return 1
    if not args.ya:
        print(f"menolak menghapus tanpa --ya: {target}")
        print("jalankan ulang dengan --ya untuk benar-benar menghapus.")
        return 1
    if config.get("gunakan_sampah"):
        folder_sampah = Path(config.get("folder_sampah", ".filetool-sampah"))
        folder_sampah.mkdir(exist_ok=True)
        tujuan = folder_sampah / target.name
        if tujuan.exists():
            print(f"filetool: sudah ada di sampah: {tujuan}", file=sys.stderr)
            return 1
        shutil.move(target, tujuan)
        print(f"ke sampah: {target} -> {tujuan}")
        return 0
    target.unlink()
    print(f"terhapus: {target}")
    return 0
  1. Baris 10 (if config.get("gunakan_sampah"):): method get() mengembalikan None saat kunci tidak ada, dan None dianggap salah — pengguna tanpa berkas konfigurasi tetap mendapat perilaku bawaan (hapus permanen).
  2. Baris 12 (folder_sampah.mkdir(exist_ok=True)): folder sampah dibuat saat pertama kali dibutuhkan; exist_ok=True menjadikan panggilan berikutnya tidak error.
bash
python3 main.py hapus files/konfig-lama.json --ya
python3 main.py daftar .filetool-sampah

Output yang Dihasilkan:

text
ke sampah: files/konfig-lama.json -> .filetool-sampah/konfig-lama.json
text
konfig-lama.json

Penanganan Galat Terpusat #

Bagian 2 menempelkan blok if not direktori.is_dir(): di hampir setiap perintah. Bagian ini menghapus semuanya dan membiarkan exception bekerja: iterdir(), stat(), dan open() milik sistem operasi sudah memunculkan FileNotFoundError, NotADirectoryError, atau PermissionError pada kondisi yang tepat. Tugas kita tinggal menangkap ketiganya di satu tempat — fungsi main():

python
def main(argv: list[str] | None = None) -> int:
    args = buat_parser().parse_args(argv)
    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.WARNING,
        format="[%(levelname)s] %(message)s",
    )
    config = muat_konfig()
    if config is None:
        return 1
    try:
        return args.func(args, config)
    except FileNotFoundError as e:
        print(f"filetool: tidak ditemukan: {e.filename or e}", file=sys.stderr)
        return 1
    except NotADirectoryError as e:
        print(f"filetool: bukan direktori: {e.filename or e}", file=sys.stderr)
        return 1
    except PermissionError as e:
        print(f"filetool: izin ditolak: {e.filename or e}", file=sys.stderr)
        return 1
  1. Baris 3-6 (logging.basicConfig(...)): satu panggilan di awal mengatur format log filetool untuk seluruh program; pesan WARNING ke atas tampil secara baku, sedangkan DEBUG hanya muncul ketika pengguna menambahkan opsi global --verbose sebelum subperintah.
  2. Baris 11 (return args.func(args, config)): seluruh perintah kini menerima (args, config); inilah satu-satunya titik pemanggilan, sehingga satu blok try menjaga semua subperintah sekaligus.
  3. Baris 12-20 (except FileNotFoundError ...): attribute e.filename berisi jalur penyebab galat yang diisi sistem operasi; ekspresi e.filename or e berjaga saat exception dibangkitkan tanpa filename.

Konsekuensi penghapusan blok pemeriksaan: perintah daftar menyusut menjadi tiga baris inti karena iterdir() sendiri yang memunculkan galat. Tiga transkrip berikut membuktikan pesan akhirnya tetap informatif:

bash
python3 main.py info tidak-ada.txt
python3 main.py daftar files/laporan.txt
mkdir -p files/terkunci && echo rahasia > files/terkunci/rahasia.txt && chmod 000 files/terkunci
python3 main.py daftar files/terkunci

Output yang Dihasilkan:

text
filetool: tidak ditemukan: tidak-ada.txt
text
filetool: bukan direktori: files/laporan.txt
text
filetool: izin ditolak: files/terkunci

Ketiganya berakhir dengan exit code 1 dan pesan di stderr — skrip tetap bisa membedakan kegagalan lewat $?, manusia tetap tahu penyebabnya. Setelah mengembalikan izin folder dengan chmod 755 files/terkunci, isi folder kembali bisa dibaca.

Logging dengan –verbose #

Pesan logging.debug() yang ditanam di muat_konfig() dan telusuri() tidak pernah muncul pada pemakaian biasa. Dengan opsi --verbose, alasan setiap keputusan penelusuran terlihat:

bash
python3 main.py --verbose cari files --pola "*.py"

Output yang Dihasilkan:

text
[DEBUG] konfigurasi dimuat dari config.json
[DEBUG] lewati files/.git (cocok pola eksklusi)
[DEBUG] lewati files/__pycache__ (cocok pola eksklusi)
[DEBUG] lewati files/catatan~ (cocok pola eksklusi)
[DEBUG] lewati files/draf-sementara.tmp (cocok pola eksklusi)
skrip-tes.py

Baris [DEBUG] ditulis logging ke stderr sedangkan hasil pencarian ditulis print() ke stdout, sehingga keduanya bisa dipisahkan dengan pengalihan aliran bila perlu.

filetool kini berperilaku sesuai konteks: konfigurasi per proyek, penghapusan yang bisa dipulihkan, galat yang seragam, dan jejak keputusan yang bisa ditelusuri. Satu kelemahan masih tersisa: setiap perubahan kode sejauh ini kita yakiki dengan mata sendiri. Bagian 4 mengotomatiskannya dengan pytest, lalu memaketkan program agar terpasang sebagai perintah filetool yang sesungguhnya.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

config.json

Unduh berkas
json
{
  "pola_eksklusi": [".git", "__pycache__", "*.tmp", "*~", ".filetool-sampah"],
  "kedalaman_rekursif": 3,
  "gunakan_sampah": true,
  "folder_sampah": ".filetool-sampah"
}

main.py

Unduh berkas
python
"""filetool: CLI pengelola file sederhana."""

import argparse
import fnmatch
import json
import logging
import os
import shutil
import sys
from datetime import datetime
from pathlib import Path

VERSI = "3.0.0"

KONFIG_BAWAAN = {
    "pola_eksklusi": [".git", "__pycache__", "*.tmp", "*~"],
    "kedalaman_rekursif": 5,
    "gunakan_sampah": False,
    "folder_sampah": ".filetool-sampah",
}


def format_size(ukuran: int) -> str:
    """Ubah ukuran dalam byte menjadi teks yang mudah dibaca manusia."""
    if ukuran < 1024:
        return f"{ukuran} B"
    for satuan in ("KB", "MB", "GB", "TB"):
        ukuran /= 1024
        if ukuran < 1024:
            return f"{ukuran:.1f} {satuan}".replace(".", ",")
    return f"{ukuran:.1f} PB".replace(".", ",")


def format_waktu(epoch: float) -> str:
    """Ubah stempel waktu epoch menjadi teks tanggal dan jam."""
    return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M")


def parse_exclude(nilai: str | list[str] | None) -> list[str]:
    """Normalisasi pola eksklusi dari string terpisah koma atau list."""
    if not nilai:
        return []
    kandidat = nilai.split(",") if isinstance(nilai, str) else list(nilai)
    return [pola.strip() for pola in kandidat if pola.strip()]


def cocok_pola(nama: str, pola: list[str]) -> bool:
    """Cek apakah sebuah nama cocok dengan pola fnmatch mana pun."""
    return any(fnmatch.fnmatch(nama, satu_pola) for satu_pola in pola)


def filter_files(daftar: list[Path], pola: list[str]) -> list[Path]:
    """Saring path yang namanya cocok pola eksklusi mana pun."""
    return [p for p in daftar if not cocok_pola(p.name, pola)]


def muat_konfig() -> dict | None:
    """Muat konfigurasi berlapis: nilai bawaan, lalu berkas JSON bila ada."""
    config = dict(KONFIG_BAWAAN)
    jalur = os.environ.get("FILETOOL_CONFIG", "config.json")
    berkas = Path(jalur)
    if not berkas.exists():
        if os.environ.get("FILETOOL_CONFIG"):
            print(f"filetool: konfigurasi tidak ditemukan: {berkas}", file=sys.stderr)
            return None
        logging.debug("tidak ada berkas konfigurasi; memakai nilai bawaan")
        return config
    try:
        with berkas.open(encoding="utf-8") as f:
            data = json.load(f)
    except PermissionError:
        print(f"filetool: izin ditolak saat membaca: {berkas}", file=sys.stderr)
        return None
    except json.JSONDecodeError as e:
        print(f"filetool: konfigurasi bukan JSON valid: {berkas} "
              f"(baris {e.lineno}, kolom {e.colno})", file=sys.stderr)
        return None
    if not isinstance(data, dict):
        print(f"filetool: akar konfigurasi harus objek: {berkas}", file=sys.stderr)
        return None
    config.update(data)
    logging.debug("konfigurasi dimuat dari %s", berkas)
    return config


def telusuri(akar: Path, config: dict) -> tuple[list[Path], list[Path]]:
    """Kumpulkan (berkas, direktori anak) secara rekursif, hormati eksklusi."""
    hasil_berkas: list[Path] = []
    hasil_direktori: list[Path] = []
    pola = parse_exclude(config.get("pola_eksklusi"))
    kedalaman = config.get("kedalaman_rekursif", 5)

    def jalan(direktori: Path, tingkat: int) -> None:
        if tingkat > kedalaman:
            logging.debug("berhenti sebelum %s (batas kedalaman %d)", direktori, kedalaman)
            return
        for entri in sorted(direktori.iterdir()):
            if cocok_pola(entri.name, pola):
                logging.debug("lewati %s (cocok pola eksklusi)", entri)
                continue
            if entri.is_dir():
                hasil_direktori.append(entri)
                jalan(entri, tingkat + 1)
            else:
                hasil_berkas.append(entri)

    jalan(akar, 1)
    return hasil_berkas, hasil_direktori


def perintah_daftar(args: argparse.Namespace, config: dict) -> int:
    direktori = Path(args.path)
    for entri in sorted(direktori.iterdir()):
        penanda = "d" if entri.is_dir() else "-"
        if args.panjang:
            stat = entri.stat()
            ukuran = "-" if entri.is_dir() else format_size(stat.st_size)
            print(f"{penanda} {ukuran:>8}  {format_waktu(stat.st_mtime)}  {entri.name}")
        else:
            akhiran = "/" if entri.is_dir() else ""
            print(f"{entri.name}{akhiran}")
    return 0


def perintah_info(args: argparse.Namespace, config: dict) -> int:
    berkas = Path(args.path)
    stat = berkas.stat()
    jenis = "direktori" if berkas.is_dir() else "berkas"
    baris = [
        ("jalur", str(berkas)),
        ("jenis", jenis),
    ]
    if berkas.is_dir():
        baris.append(("isi", f"{len(list(berkas.iterdir()))} entri"))
    else:
        baris.append(("ekstensi", berkas.suffix or "(tidak ada)"))
        baris.append(("ukuran", f"{stat.st_size} byte ({format_size(stat.st_size)})"))
    baris.append(("diubah", format_waktu(stat.st_mtime)))
    for label, nilai in baris:
        print(f"{label:<9}: {nilai}")
    return 0


def perintah_cari(args: argparse.Namespace, config: dict) -> int:
    akar = Path(args.path)
    pola = parse_exclude(config.get("pola_eksklusi")) + parse_exclude(args.kecuali)
    berkas_semua, _ = telusuri(akar, {**config, "pola_eksklusi": pola})
    hasil = sorted(b for b in berkas_semua if fnmatch.fnmatch(b.name, args.pola))
    if not hasil:
        print("tidak ada yang cocok.")
        return 0
    for jalur in hasil:
        print(jalur.relative_to(akar))
    return 0


def perintah_salin(args: argparse.Namespace, config: dict) -> int:
    sumber = Path(args.sumber)
    tujuan = Path(args.tujuan)
    if sumber.is_dir():
        print(f"filetool: sumber harus berkas, bukan direktori: {sumber}", file=sys.stderr)
        return 1
    if tujuan.is_dir():
        tujuan = tujuan / sumber.name
    if tujuan.exists():
        print(f"filetool: tujuan sudah ada: {tujuan}", file=sys.stderr)
        return 1
    shutil.copy2(sumber, tujuan)
    print(f"tersalin: {sumber} -> {tujuan}")
    return 0


def perintah_pindahkan(args: argparse.Namespace, config: dict) -> int:
    sumber = Path(args.sumber)
    tujuan = Path(args.tujuan)
    if tujuan.is_dir():
        tujuan = tujuan / sumber.name
    if tujuan.exists():
        print(f"filetool: tujuan sudah ada: {tujuan}", file=sys.stderr)
        return 1
    shutil.move(sumber, tujuan)
    print(f"dipindahkan: {sumber} -> {tujuan}")
    return 0


def perintah_hapus(args: argparse.Namespace, config: dict) -> int:
    target = Path(args.path)
    if not target.is_file():
        print(f"filetool: bukan berkas: {target}", file=sys.stderr)
        return 1
    if not args.ya:
        print(f"menolak menghapus tanpa --ya: {target}")
        print("jalankan ulang dengan --ya untuk benar-benar menghapus.")
        return 1
    if config.get("gunakan_sampah"):
        folder_sampah = Path(config.get("folder_sampah", ".filetool-sampah"))
        folder_sampah.mkdir(exist_ok=True)
        tujuan = folder_sampah / target.name
        if tujuan.exists():
            print(f"filetool: sudah ada di sampah: {tujuan}", file=sys.stderr)
            return 1
        shutil.move(target, tujuan)
        print(f"ke sampah: {target} -> {tujuan}")
        return 0
    target.unlink()
    print(f"terhapus: {target}")
    return 0


def perintah_ukuran(args: argparse.Namespace, config: dict) -> int:
    akar = Path(args.path)
    daftar_berkas, daftar_direktori = telusuri(akar, config)
    total = sum(b.stat().st_size for b in daftar_berkas)
    mentah = f"{total:,}".replace(",", ".")
    print(f"{'total':<9}: {mentah} byte ({format_size(total)})")
    print(f"{'berkas':<9}: {len(daftar_berkas)}")
    print(f"{'direktori':<9}: {len(daftar_direktori)}")
    return 0


def susun_rencana(akar: Path, pola: list[str]) -> list[tuple[Path, Path]]:
    """Rancang pemindahan setiap berkas ke folder sesuai ekstensinya."""
    rencana = []
    for berkas in filter_files(sorted(akar.iterdir()), pola):
        if not berkas.is_file():
            continue
        folder = berkas.suffix.lstrip(".") or "tanpa-ekstensi"
        rencana.append((berkas, akar / folder / berkas.name))
    return rencana


def perintah_organisasi(args: argparse.Namespace, config: dict) -> int:
    akar = Path(args.path)
    pola = parse_exclude(config.get("pola_eksklusi")) + parse_exclude(args.kecuali)
    rencana = susun_rencana(akar, pola)
    if not rencana:
        print("tidak ada berkas yang perlu diorganisasi.")
        return 0
    for sumber, tujuan in rencana:
        print(f"{sumber} -> {tujuan}")
    if args.dry_run:
        print(f"(dry-run: {len(rencana)} berkas akan dipindahkan)")
        return 0
    for sumber, tujuan in rencana:
        tujuan.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(sumber, tujuan)
    print(f"{len(rencana)} berkas dipindahkan.")
    return 0


def buat_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="filetool",
        description="Pengelola file dari terminal.",
    )
    parser.add_argument("--versi", action="version", version=f"filetool {VERSI}")
    parser.add_argument("--verbose", action="store_true",
                        help="tampilkan pesan debug yang lebih rinci")
    sub = parser.add_subparsers(dest="perintah", required=True, metavar="PERINTAH")

    p_daftar = sub.add_parser("daftar", help="tampilkan isi sebuah direktori")
    p_daftar.add_argument("path", help="direktori yang ingin ditampilkan")
    p_daftar.add_argument("--panjang", action="store_true",
                          help="tampilkan ukuran, waktu ubah, dan nama")
    p_daftar.set_defaults(func=perintah_daftar)

    p_info = sub.add_parser("info", help="tampilkan metadata sebuah berkas")
    p_info.add_argument("path", help="berkas atau direktori yang ingin diperiksa")
    p_info.set_defaults(func=perintah_info)

    p_cari = sub.add_parser("cari", help="cari berkas berdasarkan pola glob")
    p_cari.add_argument("path", help="direktori awal pencarian")
    p_cari.add_argument("--pola", default="*", help='pola nama berkas, misalnya "*.txt"')
    p_cari.add_argument("--kecuali", default="",
                        help="pola tambahan yang diabaikan, dipisah koma")
    p_cari.set_defaults(func=perintah_cari)

    p_salin = sub.add_parser("salin", help="salin sebuah berkas")
    p_salin.add_argument("sumber", help="berkas sumber")
    p_salin.add_argument("tujuan", help="berkas atau direktori tujuan")
    p_salin.set_defaults(func=perintah_salin)

    p_pindahkan = sub.add_parser("pindahkan", help="pindahkan berkas atau direktori")
    p_pindahkan.add_argument("sumber", help="berkas atau direktori sumber")
    p_pindahkan.add_argument("tujuan", help="berkas atau direktori tujuan")
    p_pindahkan.set_defaults(func=perintah_pindahkan)

    p_hapus = sub.add_parser("hapus", help="hapus sebuah berkas")
    p_hapus.add_argument("path", help="berkas yang akan dihapus")
    p_hapus.add_argument("--ya", action="store_true",
                         help="konfirmasi bahwa pengguna yakin ingin menghapus")
    p_hapus.set_defaults(func=perintah_hapus)

    p_ukuran = sub.add_parser("ukuran", help="hitung total ukuran secara rekursif")
    p_ukuran.add_argument("path", help="direktori yang ingin diukur")
    p_ukuran.set_defaults(func=perintah_ukuran)

    p_organisasi = sub.add_parser("organisasi", help="urutkan berkas ke folder per ekstensi")
    p_organisasi.add_argument("path", help="direktori yang akan diorganisasi")
    p_organisasi.add_argument("--dry-run", action="store_true",
                              help="tampilkan rencana tanpa memindahkan apa pun")
    p_organisasi.add_argument("--kecuali", default="",
                              help="pola tambahan yang diabaikan, dipisah koma")
    p_organisasi.set_defaults(func=perintah_organisasi)

    return parser


def main(argv: list[str] | None = None) -> int:
    args = buat_parser().parse_args(argv)
    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.WARNING,
        format="[%(levelname)s] %(message)s",
    )
    config = muat_konfig()
    if config is None:
        return 1
    try:
        return args.func(args, config)
    except FileNotFoundError as e:
        print(f"filetool: tidak ditemukan: {e.filename or e}", file=sys.stderr)
        return 1
    except NotADirectoryError as e:
        print(f"filetool: bukan direktori: {e.filename or e}", file=sys.stderr)
        return 1
    except PermissionError as e:
        print(f"filetool: izin ditolak: {e.filename or e}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())