Published on 5 min read

Preview AI File-Renaming Plans Before Touching Files

An AI assistant can turn vague filenames into a useful naming plan. It can also suggest two identical destinations or a name that overwrites an existing file. Ask for a proposal, not a script with permission to change your folder.

This tutorial builds a read-only Python checker for small JSON rename plans. It works offline on a case-sensitive Linux filesystem; no model account or additional packages are needed. The examples are synthetic, not measured AI outputs.

Original document cards stay in a folder while a magnifying glass reviews proposed name labels and highlights two conflicting proposals.

1. Separate the proposal from execution

Give your assistant an approved filename inventory and this contract:

  • Return only a JSON list of objects containing source and target.
  • Use basenames, never directory paths. Names must start with an ASCII letter or digit, contain only ASCII letters, digits, dots, underscores, or hyphens, and be at most 100 characters.
  • Each source must be an existing regular file, not a symbolic link. Use each source and destination only once.
  • Never target an occupied name, even if another row proposes moving that file away.

This deliberately excludes swaps, chains, hidden files, and names containing spaces. Keep private filenames out of unapproved services. If you change the naming rule, add regex acceptance tests first.

Do not test an AI-written renaming loop against originals. On Unix, Path.rename() can overwrite an existing file when permissions allow it. Python documents that platform-dependent behavior.

2. Save the read-only checker

Save this as audit_renames.py in a fresh working directory. Use Python 3.10 or later. It validates the whole plan before printing any preview and contains no rename or write operation.

import json
from pathlib import Path
import re
import sys

NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}")


def audit(root, plan_path):
    with open(plan_path, encoding="utf-8") as stream:
        plan = json.load(stream)
    if not isinstance(plan, list) or not plan:
        raise ValueError("plan must be a nonempty list")
    occupied = {entry.name for entry in root.iterdir()}
    sources, targets = set(), set()
    for row in plan:
        if not isinstance(row, dict) or set(row) != {"source", "target"}:
            raise ValueError("each row needs only source and target")
        source, target = row["source"], row["target"]
        if any(not isinstance(n, str) or not NAME.fullmatch(n)
               for n in (source, target)):
            raise ValueError("names must be simple ASCII basenames")
        original = root / source
        if original.is_symlink() or not original.is_file():
            raise ValueError(f"not a regular source file: {source}")
        if source in sources:
            raise ValueError(f"repeated source: {source}")
        if target in occupied:
            raise ValueError(f"target already exists: {target}")
        if target in targets:
            raise ValueError(f"duplicate target: {target}")
        sources.add(source)
        targets.add(target)
    return plan


if __name__ == "__main__":
    if len(sys.argv) != 3:
        raise SystemExit("Usage: python3 audit_renames.py DIRECTORY PLAN.json")
    try:
        plan = audit(Path(sys.argv[1]), sys.argv[2])
    except (OSError, ValueError) as error:
        raise SystemExit(f"FAIL: {error}")
    for row in plan:
        print(f"PREVIEW {row['source']} -> {row['target']}")
    print(f"PASS: {len(plan)} planned renames; no files changed")

The standard-library JSON decoder reads data, not executable Python. Parsing alone does not enforce our naming or collision rules; the loop supplies those checks.

3. Make a disposable fixture

Save this as make_fixture.py, beside the checker. Run it only in the fresh working directory: it creates three text files, one local symbolic link, and seven plans. These plans deliberately exercise different outcomes.

import json
from pathlib import Path

root = Path("rename-demo")
root.mkdir()
for name in ("report-01.txt", "report-02.txt", "archive.txt"):
    (root / name).write_text(name + "\n", encoding="utf-8")
(root / "shortcut.txt").symlink_to("report-01.txt")


def row(source, target):
    return {"source": source, "target": target}


plans = {
    "good": [row("report-01.txt", "notes-01.txt"),
             row("report-02.txt", "notes-02.txt")],
    "collision": [row("report-01.txt", "notes.txt"),
                  row("report-02.txt", "notes.txt")],
    "occupied": [row("report-01.txt", "archive.txt")],
    "escape": [row("report-01.txt", "../notes.txt")],
    "missing": [row("report-99.txt", "notes.txt")],
    "repeated": [row("report-01.txt", "notes-01.txt"),
                 row("report-01.txt", "notes-02.txt")],
    "symlink": [row("shortcut.txt", "notes.txt")],
}
for name, plan in plans.items():
    Path(name + ".json").write_text(json.dumps(plan), encoding="utf-8")

The fixture refuses to reuse an existing rename-demo directory. It does not need your real documents or an AI-generated response.

4. Run positive and negative checks

In Bash, run:

python3 make_fixture.py
for plan in good collision occupied escape missing repeated symlink; do
    python3 audit_renames.py rename-demo "$plan.json"
    echo "exit=$?"
done

Do not enable set -e for this demonstration: six failures are intentional. The good plan prints:

PREVIEW report-01.txt -> notes-01.txt
PREVIEW report-02.txt -> notes-02.txt
PASS: 2 planned renames; no files changed

Locally verified with Python 3.11.4:

Plan Result Exit
good Three stdout lines above; empty stderr 0
collision FAIL: duplicate target: notes.txt 1
occupied FAIL: target already exists: archive.txt 1
escape FAIL: names must be simple ASCII basenames 1
missing FAIL: not a regular source file: report-99.txt 1
repeated FAIL: repeated source: report-01.txt 1
symlink FAIL: not a regular source file: shortcut.txt 1

All failing results go to stderr with empty stdout. The Bash loop prints each exit status separately. File names, contents, and the link target remained unchanged after all seven runs. As with testing AI-written SQL, deliberate failures check that the checker actually rejects mistakes.

5. Troubleshoot and respect the limits

  • Invalid JSON: remove Markdown wrappers and commentary. Do not execute the response to repair it.
  • Occupied or duplicate target: ask for a new destination. Never bypass the check by deleting the existing file.
  • Missing source: refresh the inventory rather than guessing which document was intended.
  • Spaces or accented names rejected: this is the narrow tutorial contract, not a filesystem limitation. Extend the policy and fixtures explicitly.

Passing means the proposal meets these checks, not that its names describe the documents correctly. Review meaning and extensions yourself. The inventory is a momentary view, not a filesystem lock; directory changes can invalidate it. Python’s directory-iteration reference explains that concurrent additions and removals have unspecified inclusion.

This is not a production renaming tool or a portable collision detector for Windows and case-insensitive volumes. Keep execution separate, back up originals, and test any future executor on copies with its own no-overwrite and recovery controls.

Practical takeaway

Let AI propose names while deterministic checks reject broken plans. Keep the accepted plan and its result, then add each new failure to your continuous-testing fixtures. A preview is useful precisely because it changes nothing.

Related articles

Latest articles