Skip to content

Implement rename - #632

Open
mmclinton wants to merge 2 commits into
uutils:mainfrom
mmclinton:rename-implement
Open

Implement rename#632
mmclinton wants to merge 2 commits into
uutils:mainfrom
mmclinton:rename-implement

Conversation

@mmclinton

Copy link
Copy Markdown
Contributor

Closes #629.

rename replaces a substring in filenames. This is the whole of rename(1): -v, -s, -n, -a, -l, -o, -i, the three required operands, and the documented exit statuses. No option is half implemented.

I built this against util-linux 2.42.2 and cross checked it against 2.40.4, comparing stdout, stderr, exit status and the resulting directory tree over roughly a thousand invocations.

Decisions I made inside the port

POSIXLY_CORRECT is honored by rewriting argv. With that variable set, option permutation stops, so everything after the first non option is a filename however much it looks like a flag. clap has no concept of this, so argv.rs inserts a -- where getopt would have stopped scanning and hands the result to clap. It makes rename the only utility in this project that touches argv before clap sees it. Dropping it costs nothing except that rename then diverges here the way every other utility already does.

Filenames are OsStr end to end, and the substitution engine is generic over the code unit: u8 on unix, u16 on Windows. That is what encoding.rs is for. A name that is not valid Unicode is exactly the kind of name people reach for rename to fix, so it has to survive the match, the rename, the -v line and the error message; carrying it through a String would replace it with U+FFFD.

Diagnostics are written as bytes rather than through show_error!, for the same reason. Display writes &str, so a message that carries a filename losslessly and a Display impl are mutually exclusive. RenameError therefore has no Display and writes its own rename: prefix.

stdout is block buffered when it is not a terminal. C util-linux's stdout is line buffered on a tty and block buffered anywhere else, and Rust's is line buffered everywhere. That is not only a syscall count: under a write limit, line buffering stops our loop part way through a run C util-linux completes.

-i accepts the C locale answer set. C util-linux takes the answers it accepts from the locale's YESEXPR; we accept an answer whose first character is y or Y. It is two sided: under a locale whose YESEXPR is ^[qQ] C util-linux accepts q and rejects y. Under LC_ALL=C the two are identical. Matching it means declaring an rpmatch extern ourselves and calling setlocale, which is process global state inside a utility that is supposed to stay embeddable.

Exit status 64

rename(1) documents 64 as "unanticipated error occurred" and names nothing that produces it. This port cannot return it: exit status is set in four places and the reachable values are 0, 1, 2 and 4. I also never saw C util-linux return it, including from a deliberate hunt (memory exhaustion, a fifo, a dangling link, /proc/self/mem), so there was no case to map onto it. I am not claiming C util-linux cannot produce a 64, only that I could not find what does. If someone knows, I will wire it up.

What the tests do not cover

64 integration tests, plus 24 unit tests on the substitution engine. Three deficiencies worth naming:

  • The unit tests do not run under cargo test from the workspace root, because the root package is the only default member. cargo test -p uu_rename runs them. This is not specific to rename; lscpu and lsmem have the same blind spot.
  • Every test needing a symlink fixture is #[cfg(unix)], because uutests' symlink helpers unwrap and Windows needs SeCreateSymbolicLinkPrivilege or Developer Mode, so on a runner without it the fixture would panic rather than skip. -s behavior and the Windows arm of encoding::symlink are compiled but have never been executed anywhere.
  • The -i prompt decides how to read stdin from a tcgetattr probe at startup. Nothing exercises that probe, because it needs a pty and there is no test in this project that drives one.

mmc added 2 commits August 19, 2026 21:46
rename replaces a substring in filenames. The option surface follows
rename(1): -v, -s, -n, -a, -l, -o and -i, with all three operands
required. Behavior throughout is what util-linux 2.42.2 and 2.40.4 were
both observed doing.

The parts a reader should not have to reverse engineer:

- The exit status is two counters rather than bit flags. 0 means
  something was renamed, 1 that everything failed, 2 a mix, and 4 that
  nothing matched. An operand that matched nothing, that -o skipped, or
  that was declined at an -i prompt counts as neither, so it can neither
  degrade a success nor promote a failure.
- Only the final path component is rewritten, unless either operand
  holds a separator, which widens the scope to the whole path. Trailing
  separators are stripped from the operand, but the existence check
  still sees the operand exactly as it was typed.
- An empty substring inserts at every code-unit boundary rather than
  matching nowhere, and a substring equal to its replacement exits 4
  before any syscall runs.
- The two overwrite guards ask different questions deliberately. The
  default path uses a check that follows symlinks, so a dangling
  destination is clobbered; -s lstats the new target name, so a dangling
  entry blocks it.
- Filenames are carried as OsStr and the substitution engine is generic
  over the code unit: bytes on unix, UTF-16 units on Windows. A name
  that is not valid Unicode survives the report and the diagnostics
  without being replaced.

Known divergences, each structural to clap and Rust rather than a defect
in the port: the --help and --version layout, ANSI escapes on a
terminal, the eager mutual-exclusion check, the rpmatch answer set, and
SIGPIPE, which Rust ignores and the reference dies of.
Sixty-four tests: the exit-status tally, the three substitution modes,
the path-scope rule, both overwrite guards, symlink mode, byte-oriented
names, and the test_invalid_arg smoke test every other util carries.

They defend the decisions a refactor would plausibly undo rather than
restating what clap already guarantees. The two overwrite predicates and
their opposite treatment of a dangling destination, the short circuit
that runs before any syscall, and the order of stripping against the
existence check each have a test whose only job is to fail if someone
tidies them away.

Diagnostics are asserted only as far as we write them. The errno text
after our half belongs to libc, so the suite does not pin strerror
strings on platforms it cannot run.

The substitution engine keeps its unit tests beside it in subst.rs.
`cargo test` from the workspace root does not run them, because the root
package is the only default member; `cargo test -p uu_rename` does.
@mmclinton

Copy link
Copy Markdown
Contributor Author

Fixed the initial macOS CI failure.

test_a_symlink_that_cannot_be_created_leaves_no_link_behind induced the failure by handing symlink(2) an empty target. ENOENT for an empty target is Linux-only; the BSD call macOS inherits creates the link instead.

So on macOS the call succeeded, rename exited 0, and .fails() panicked.

The fix was trivial: gated that test to Linux, utility unchanged. All checks now pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement rename

1 participant