---
name: file_renaming_automation
description: "Batch rename files in one directory with pattern rules: build a rename plan from find and replace, numbering, case, space, and date prefix rules, preview it as a dry run, detect collisions and illegal names before touching anything, apply through a saved rename map, and undo from that map. Use when a set of files needs consistent new names in place, with no folder moves and no deletions."
metadata: { "includeInPrompt": true }
---

# File Renaming Automation

## Purpose

Rename a batch of files in place, in one directory at a time, from a written set of pattern rules. The work is: build a rename plan, preview it as a dry run, catch every collision and illegal name before any file is touched, save the clean entries to a rename map, apply from that map only, and keep the map so the batch can be undone exactly. A rename changes a name. It does not change file contents, move a file to another folder, or remove a file.

Scope:
- digital-file-organizer covers auditing a tree, duplicate detection by hash, folder structure proposals, and approval-before-move organizing. This skill covers the rename operation itself: plan, collision check, apply, and undo. It does not reorganize folders and it does not delete duplicates. Two files with the same content stay where they are; if their planned names collide, both are blocked and listed until distinct names or new rules are chosen.
- This skill works on the regular files at the top level of one directory per run. Subfolders are not renamed and are not scanned for files in this workflow. Run the skill once per directory.
- Extensions are part of the name handling: case rules apply to the stem and the extension, and find and replace runs on the stem only, so a rule cannot change a file type by accident.
- Verification note, 2026-10-16: rename_plan.py below (Python 3.12.3, standard library only) was run against a fixture directory built in /tmp/rename_fixture and a clean fixture in /tmp/rename_clean. The messy fixture plan returned files: 7, ok: 1, collisions: 4, illegal: 1, unchanged: 1, with exit code 1. The clean fixture plan returned files: 3, ok: 3, collisions: 0, illegal: 0, unchanged: 0, wrote a map with entries: 3, applied with applied: 3, and undid with undone: 3, each with exit code 0. Both fixtures were temporary test directories. No real files were renamed.

## Workflow

1. Inventory the directory before writing any rule.
   - List the regular files at the top level of the target directory, sorted by name. That sorted list is the input set, and the plan output must account for every file on it: ok, collision, illegal, or unchanged.
   - Note files whose current names already look like planned output. A file that keeps its name can still block another file that wants that name, and the plan flags that case instead of letting the apply step fail partway through.
   - Note extensions present in the set. A mixed set is fine, but record the extensions so a case rule that lowercases .JPG to .jpg is a chosen change, recorded in the plan, and not a surprise.
   - If a file in the directory changes after the plan is built, the plan is stale. Rebuild it. Numbering and duplicate checks both depend on the exact input set at plan time.

2. Write the rules down, in the order the planner applies them.
   - The planner applies rules to the stem in one fixed order: spaces, case, find and replace, date prefix, numbering. Write the rule set in that order in the run notes, so the plan can be rebuilt and checked later.
   - Spaces: keep, or replace each space with an underscore, a hyphen, or nothing. State the choice. Most collision pairs in practice come from this step combined with case, since two names that differ only by a space or a capital letter land on one target.
   - Case: keep, lower, upper, or title. Lower and upper apply to the stem and the extension. Title applies to the stem only.
   - Find and replace: one find string and one replace string, applied to the stem after spaces and case. Match the post-case text: with case set to lower, find img, not IMG. An empty find string means no replacement runs.
   - Date prefix: when on, the target gets a YYYY-MM-DD_ prefix taken from the file modification time, read in UTC so the same file gives the same prefix on any machine. Check one known file against its expected date before trusting a full plan built with this rule.
   - Numbering: when on, each file gets a _001, _002 suffix on the stem, assigned in sorted name order across the whole input set, starting at 001. Numbering makes targets distinct within the set, but it does not fix a target that collides with a file outside the rename entries, and it does not fix an illegal name.

3. Build the plan as a dry run.
   - Run the planner from the Operating Rules section with the rule flags that match the written rule set. The plan step renames nothing. It prints one line per file and a count block, and it exits with code 1 when any file is in collision or illegal status, and 0 when no file is blocked.
   - Read the full plan line by line, not only the counts. Each line gives the current name, the planned target, and the status. A count block that looks right can still hide one wrong target on a single line.
   - Confirm the dry run changed nothing: list the directory again and compare it with the step 1 inventory. Any difference means something else touched the directory, and the plan is rebuilt after the difference is understood.

4. Clear collisions and illegal names before applying anything.
   - collision_duplicate_target: two or more files in the set plan to the same target name. Fix by changing a rule, adding numbering, or assigning a distinct target to one file in a follow-up run. Never pick a winner and rename over the other file.
   - collision_target_exists: the planned target already exists in the directory and is not the file itself. The existing file may be unchanged in this plan and still block the rename. Treat it as blocked, not as permission to overwrite.
   - Illegal checks run on the planned target, and any one of them blocks the file: illegal_char for any of \ / : * ? " < > | or a control character, illegal_empty_stem, illegal_trailing for a trailing dot or space, illegal_reserved_name for CON, PRN, AUX, NUL, COM1 to COM9, and LPT1 to LPT9, and illegal_too_long for a name over 255 bytes in UTF-8. The colon check matters even on systems that allow a colon in a name, because the name breaks when the file moves to a system that does not.
   - A file can carry more than one reason, for example a duplicate target that also already exists. Clear every reason on a line before that file can enter the map.
   - Blocked files are left out of the rename map. They keep their current names and appear on the blocked list in the output. Fixing a blocked file is a new plan, not a hand edit to a saved map.

5. Save the rename map and read it before applying.
   - Re-run the planner with --write-map once the plan is clean, or accept a map that holds only the ok entries from a partly blocked plan and handle the blocked files separately. The map is a JSON file with the directory path, the rule set, and one entry per rename with src and dst as bare file names.
   - Read the map file itself before applying. Check the directory path is the intended one, the entry count matches the ok count from the plan, and each dst matches the plan line for its src. A map that disagrees with the plan is not applied; rebuild it from a fresh plan.
   - Keep the map after applying. The map is the only undo record. A batch with no saved map has no reliable undo, because target names alone do not prove which source each file came from.

6. Apply from the map only.
   - Run the apply command on the saved map. Apply first checks every entry: each source must exist and each target must not exist. If any check fails, apply stops before renaming anything and prints the failing name. Fix the directory state or rebuild the plan; do not edit the map to skip the failing entry and push on in the same run.
   - Apply renames in map order and prints one APPLY line per file plus a final applied count. Compare that count with the map entry count. A mismatch means the run stopped early and the directory is in a mixed state that the undo step can still reverse from the same map.
   - After applying, list the directory and compare it with the plan: every planned target present, every blocked or unchanged file still under its old name, and no extra files. Record the listing in the run notes.

7. Undo from the map when the batch is wrong.
   - Run the undo command on the same map. Undo reverses each entry, in reverse order: each current target must exist and each original name must be free, checked for all entries before any rename runs, then it prints one UNDO line per file plus a final undone count.
   - After undo, list the directory and compare it with the step 1 inventory. The names must match exactly, including case and spaces. Then close the run notes with the fact that the batch was undone, so a later run does not treat the map as a pending apply.
   - Undo restores names only. If file contents were edited after the apply, undo still restores the names and says nothing about contents.

8. Record the run.
   - Save in the run notes: the directory, the rule set in planner order, the plan count block, the map path, the applied or undone count, and the blocked list with the fix chosen for each blocked file.
   - A second batch in the same directory starts from a fresh inventory and a fresh plan. Never reuse a map from an earlier batch, even when the rules look the same, because the input set may have changed.

## Output Contract

Deliver these items, in this order:

1. Inventory: the sorted list of files in the target directory before any rule runs, with the extensions present noted.
2. Rule set: the rules in planner order (spaces, case, find, replace, date prefix on or off, numbering on or off) exactly as passed to the planner.
3. Plan output: the full output of rename_plan.py for the run. On the messy worked fixture in the Operating Rules section the headline numbers are: files: 7, ok: 1, collisions: 4, illegal: 1, unchanged: 1, with exit code 1.
4. Rename map: the saved JSON map for the batch, with the directory, the rule set, and the entries. On the clean worked fixture the map holds entries: 3.
5. Apply or undo log: the full apply or undo output. On the clean worked fixture, apply printed applied: 3 and undo printed undone: 3, each with exit code 0.
6. Blocked list: one row per blocked file with its current name, planned target, every reason on its plan line, and the fix chosen. Files with no fix chosen stay on this list with their names unchanged.
7. Limits: anything the run did not cover, stated plainly (for example subfolders not scanned, a directory that changed mid-run and forced a rebuild, or blocked files held for a later run).

## Operating Rules

1. Dry run first, always. No file is renamed from a rule set that has not produced a plan that was read line by line. The plan is the review surface; the map is built from the plan, and apply is built from the map.
2. Never overwrite. A target that already exists blocks the rename. There is no force flag in the planner or the apply step, and no step in this skill removes a file to free a name.
3. Only ok entries enter the map. Collision, illegal, and unchanged files are excluded from the map and stay under their current names. Hand editing a map to add a blocked file back in voids the run; rebuild the plan instead.
4. Apply and undo check everything before renaming anything. All sources must exist and all targets must be free before the first rename in either direction. A failed precheck renames nothing.
5. Undo comes from the saved map, never from memory or from reversing the rules by hand. If the map is lost, list the directory, match files to the recorded plan lines one by one, and treat any file that cannot be matched as unresolved rather than guessing its old name.
6. Illegal names stay blocked even when the local system allows them. The illegal set in the planner is a portability floor: reserved device names, trailing dots or spaces, names over 255 bytes, and the characters \ / : * ? " < > | do not ship in a finished batch.
7. Rebuild the plan after any change to the directory or the rules. Numbering, date prefixes, and duplicate checks all read live directory state, so a plan built before a change can name targets that no longer make sense.
8. Every number in the output comes from the planner output, the map file, or the apply or undo log for that run. Counts from an earlier batch, even in the same directory, are not carried over.
9. Use the planner below for the plan, map, apply, and undo steps. Save it as rename_plan.py, run its fixture modes to reproduce the worked examples, and put its full plan output in the deliverable as item 3 of the Output Contract. The script exits with code 1 when a plan holds a collision or an illegal name, or when an apply or undo precheck fails, and 0 on a clean run.

### Rename planner (rename_plan.py)

Run: `python3 rename_plan.py --make-fixture /tmp/rename_fixture` builds the messy worked fixture, and `python3 rename_plan.py --make-clean-fixture /tmp/rename_clean` builds the clean one. Plan the messy fixture with `python3 rename_plan.py plan /tmp/rename_fixture --spaces underscore --case lower --find img --replace photo`. Plan and map the clean fixture with `python3 rename_plan.py plan /tmp/rename_clean --spaces underscore --case lower --find img --replace photo --date-prefix --number --write-map /tmp/rename_clean_map.json`, then run `python3 rename_plan.py apply /tmp/rename_clean_map.json` and `python3 rename_plan.py undo /tmp/rename_clean_map.json`. The script uses only the Python standard library (verified on Python 3.12.3). Both fixtures set every file modification time to 2026-01-15 12:00 UTC, which is why the date prefix in the clean plan reads 2026-01-15 for all three files.

Expected output for the messy fixture plan (exit code 1, because 4 files are in collision and 1 is illegal):

```text
files: 7
ok: 1
collisions: 4
illegal: 1
unchanged: 1
COLLISION IMG 001.jpg -> photo_001.jpg [collision_duplicate_target]
COLLISION IMG 002.jpg -> photo_002.jpg [collision_duplicate_target, collision_target_exists]
RENAME Report Final.docx -> report_final.docx
ILLEGAL bad:name.txt -> bad:name.txt [illegal_char]
COLLISION img 001.jpg -> photo_001.jpg [collision_duplicate_target]
UNCHANGED notes.txt
COLLISION photo_002.jpg -> photo_002.jpg [collision_duplicate_target]
```

The messy fixture is built to trip each check: IMG 001.jpg and img 001.jpg both plan to photo_001.jpg after spaces, case, and find and replace run, IMG 002.jpg plans to photo_002.jpg which the unchanged photo_002.jpg already holds (and photo_002.jpg is itself flagged because its name is claimed by another file), bad:name.txt plans to a name with a colon in it, and notes.txt plans to its own name. Only Report Final.docx, planning to report_final.docx, is ok.

Expected output for the clean fixture plan, with the map written to /tmp/rename_clean_map.json (exit code 0):

```text
files: 3
ok: 3
collisions: 0
illegal: 0
unchanged: 0
RENAME IMG 101.jpg -> 2026-01-15_photo_101_001.jpg
RENAME IMG 102.jpg -> 2026-01-15_photo_102_002.jpg
RENAME My Notes.txt -> 2026-01-15_my_notes_003.txt
map_written: /tmp/rename_clean_map.json entries: 3
```

Expected apply output for that map (exit code 0), followed by the undo output (exit code 0), which restores the three original names IMG 101.jpg, IMG 102.jpg, and My Notes.txt:

```text
APPLY IMG 101.jpg -> 2026-01-15_photo_101_001.jpg
APPLY IMG 102.jpg -> 2026-01-15_photo_102_002.jpg
APPLY My Notes.txt -> 2026-01-15_my_notes_003.txt
applied: 3
UNDO 2026-01-15_my_notes_003.txt -> My Notes.txt
UNDO 2026-01-15_photo_102_002.jpg -> IMG 102.jpg
UNDO 2026-01-15_photo_101_001.jpg -> IMG 101.jpg
undone: 3
```

rename_plan.py (Python 3.12.3, standard library only). Save this code as rename_plan.py:

```python
#!/usr/bin/env python3
"""Build a batch rename plan, check collisions and illegal names,
apply a saved rename map, and undo from that map. Python 3.12.3,
standard library only.

Usage:
  python3 rename_plan.py --make-fixture DIR
  python3 rename_plan.py --make-clean-fixture DIR
  python3 rename_plan.py plan DIR [rule flags] [--write-map MAP.json]
  python3 rename_plan.py apply MAP.json
  python3 rename_plan.py undo MAP.json

Rule flags for plan, applied in this fixed order to the stem:
  --spaces underscore|hyphen|remove|keep   (default keep)
  --case lower|upper|title|keep           (default keep; applies to
                                           stem and extension)
  --find TEXT --replace TEXT              (default: no replacement;
                                           runs after spaces and case,
                                           so match the post-case text)
  --date-prefix                           (prefix YYYY-MM-DD_ from the
                                           file mtime, in UTC)
  --number                                (suffix _001, _002, ... in
                                           sorted name order)

A plan is a dry run: it renames nothing. It writes a rename map only
when --write-map is given, and the map holds only entries whose
status is ok. Apply and undo read that map. Undo reverses apply.
"""
import argparse
import json
import os
import sys
from datetime import datetime, timezone

ILLEGAL_CHARS = set('\\/:*?"<>|')
RESERVED = {"CON", "PRN", "AUX", "NUL"} | {
    f"{p}{n}" for p in ("COM", "LPT") for n in range(1, 10)}


def illegal_reasons(name):
    reasons = []
    stem = name.rsplit(".", 1)[0] if "." in name else name
    if any(c in ILLEGAL_CHARS or ord(c) < 32 for c in name):
        reasons.append("illegal_char")
    if not stem.strip():
        reasons.append("illegal_empty_stem")
    if name.endswith(".") or name.endswith(" "):
        reasons.append("illegal_trailing")
    if stem.upper() in RESERVED:
        reasons.append("illegal_reserved_name")
    if len(name.encode("utf-8")) > 255:
        reasons.append("illegal_too_long")
    return reasons


def build_target(name, path, rules, index):
    if "." in name and not name.startswith("."):
        stem, ext = name.rsplit(".", 1)
        ext = "." + ext
    else:
        stem, ext = name, ""
    if rules["spaces"] == "underscore":
        stem = stem.replace(" ", "_")
    elif rules["spaces"] == "hyphen":
        stem = stem.replace(" ", "-")
    elif rules["spaces"] == "remove":
        stem = stem.replace(" ", "")
    if rules["case"] == "lower":
        stem, ext = stem.lower(), ext.lower()
    elif rules["case"] == "upper":
        stem, ext = stem.upper(), ext.upper()
    elif rules["case"] == "title":
        stem = stem.title()
    if rules["find"]:
        stem = stem.replace(rules["find"], rules["replace"])
    if rules["date_prefix"]:
        day = datetime.fromtimestamp(
            os.path.getmtime(path), tz=timezone.utc).date().isoformat()
        stem = f"{day}_{stem}"
    if rules["number"]:
        stem = f"{stem}_{index:03d}"
    return stem + ext


def plan_dir(directory, rules):
    names = sorted(n for n in os.listdir(directory)
                   if os.path.isfile(os.path.join(directory, n)))
    targets = {}
    rows = []
    for i, name in enumerate(names, start=1):
        target = build_target(
            name, os.path.join(directory, name), rules, i)
        targets.setdefault(target, []).append(name)
        rows.append([name, target, [], ""])
    existing = set(names)
    for row in rows:
        name, target = row[0], row[1]
        reasons = illegal_reasons(target)
        if len(targets[target]) > 1:
            reasons.append("collision_duplicate_target")
        if target != name and target in existing:
            reasons.append("collision_target_exists")
        if reasons:
            status = ("illegal" if any(r.startswith("illegal")
                                       for r in reasons)
                      else "collision")
        elif target == name:
            status = "unchanged"
        else:
            status = "ok"
        row[2], row[3] = reasons, status
    return rows


def report(rows, directory, rules, write_map=None):
    counts = {"ok": 0, "collision": 0, "illegal": 0, "unchanged": 0}
    for row in rows:
        counts[row[3]] += 1
    print(f"files: {len(rows)}")
    print(f"ok: {counts['ok']}")
    print(f"collisions: {counts['collision']}")
    print(f"illegal: {counts['illegal']}")
    print(f"unchanged: {counts['unchanged']}")
    for name, target, reasons, status in rows:
        suffix = f" [{', '.join(reasons)}]" if reasons else ""
        if status == "ok":
            print(f"RENAME {name} -> {target}")
        elif status == "unchanged":
            print(f"UNCHANGED {name}")
        else:
            print(f"{status.upper()} {name} -> {target}{suffix}")
    if write_map:
        entries = [{"src": n, "dst": t} for n, t, _, s in rows
                   if s == "ok"]
        data = {"version": 1, "directory": os.path.abspath(directory),
                "rules": rules, "entries": entries}
        with open(write_map, "w", encoding="utf-8") as fh:
            json.dump(data, fh, indent=2)
        print(f"map_written: {write_map} entries: {len(entries)}")
    return 1 if (counts["collision"] or counts["illegal"]) else 0


def load_map(path):
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)


def apply_map(path, undo=False):
    data = load_map(path)
    directory = data["directory"]
    entries = list(reversed(data["entries"])) if undo else data["entries"]
    for e in entries:
        src_name, dst_name = (e["dst"], e["src"]) if undo else (
            e["src"], e["dst"])
        src = os.path.join(directory, src_name)
        dst = os.path.join(directory, dst_name)
        if not os.path.isfile(src):
            print(f"missing_source: {src_name}")
            return 1
        if os.path.exists(dst):
            print(f"target_exists: {dst_name}")
            return 1
    done = 0
    for e in entries:
        src_name, dst_name = (e["dst"], e["src"]) if undo else (
            e["src"], e["dst"])
        os.rename(os.path.join(directory, src_name),
                  os.path.join(directory, dst_name))
        print(f"{'UNDO' if undo else 'APPLY'} {src_name} -> {dst_name}")
        done += 1
    print(f"{'undone' if undo else 'applied'}: {done}")
    return 0


def make_files(directory, spec):
    os.makedirs(directory, exist_ok=True)
    stamp = datetime(2026, 1, 15, 12, 0, tzinfo=timezone.utc).timestamp()
    for name, body in spec:
        path = os.path.join(directory, name)
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(body)
        os.utime(path, (stamp, stamp))
    print(f"fixture_files: {len(spec)} in {directory}")
    return 0


def main():
    args = sys.argv[1:]
    if len(args) == 2 and args[0] == "--make-fixture":
        return make_files(args[1], [
            ("IMG 001.jpg", "a"), ("img 001.jpg", "b"),
            ("IMG 002.jpg", "c"), ("photo_002.jpg", "d"),
            ("bad:name.txt", "e"), ("notes.txt", "f"),
            ("Report Final.docx", "g")])
    if len(args) == 2 and args[0] == "--make-clean-fixture":
        return make_files(args[1], [
            ("IMG 101.jpg", "a"), ("IMG 102.jpg", "b"),
            ("My Notes.txt", "c")])
    if args and args[0] in ("apply", "undo") and len(args) == 2:
        return apply_map(args[1], undo=(args[0] == "undo"))
    if args and args[0] == "plan":
        parser = argparse.ArgumentParser(prog="rename_plan.py plan")
        parser.add_argument("directory")
        parser.add_argument("--spaces", default="keep",
                            choices=["keep", "underscore", "hyphen",
                                     "remove"])
        parser.add_argument("--case", default="keep",
                            choices=["keep", "lower", "upper", "title"])
        parser.add_argument("--find", default="")
        parser.add_argument("--replace", default="")
        parser.add_argument("--date-prefix", action="store_true")
        parser.add_argument("--number", action="store_true")
        parser.add_argument("--write-map", default=None)
        ns = parser.parse_args(args[1:])
        rules = {"spaces": ns.spaces, "case": ns.case,
                 "find": ns.find, "replace": ns.replace,
                 "date_prefix": ns.date_prefix, "number": ns.number}
        rows = plan_dir(ns.directory, rules)
        return report(rows, ns.directory, rules, ns.write_map)
    print("usage: rename_plan.py --make-fixture DIR | "
          "--make-clean-fixture DIR | plan DIR [flags] | "
          "apply MAP.json | undo MAP.json")
    return 2


if __name__ == "__main__":
    raise SystemExit(main())
```
