Leading Zeros Utility
- 12 Devlogs
- 23 Total hours
I am building a CLI to add and remove leading zeros from the ends of filenames. eg. problem-01 <-> problem-001
I am building a CLI to add and remove leading zeros from the ends of filenames. eg. problem-01 <-> problem-001
THIS IS THE FINAL DEVLOG!!!! We got a working release! After my last devlog, I added some unit tests for skip_rename(), and I added the Github default Rust workflow with some modifications to the repo to automatically test any new commits against the test suit, which is most of my codebase. Also added a test workflow to the homebrew tap so it’s easier to see that all is working.
I’m going to keep this one quick because it just got deleted for being too long.
The previously mentioned behaviour of overwriting filenames has been fixed and has it’s own unit test now. It was given an exception before the actual rename.
All the rename logic was duplicated between dec and !dec, so I flattened that.
I pulled out the pre-rename exception logic into a new helper: skip_rename(). It needs unit tests of its own.
Byyyeeeeee!!!
Hello again! You know what they say, new commit, new devlog!
We got MORE UNIT TESTS!!! The get_filenames() fn was formerly the only function in helpers.rs that had no unit tests, so I added 6 unit tests for it. They have about the same general structure as the unit tests for change_leading_zeros().
I was trying to make my code prettier, and I accidentally screwed up an ownership issue. My unit tests made use of tempdirs with the tempfile crate to test change_leading_zeros() and get_filenames.
This is the original pattern for getting the temp dir’s path:
let binding = tempdir().unwrap();
let dir = binding.path();
// stuff can be done with dir now
This is what I changed it to:
let dir = tempdir().unwrap().path().to_owned();
When the tempdir gets dropped, it also deletes the directory, which makes dir a path to a dir that doesn’t exist. Everything compiles fine, but the code panics because the tempdir doesn’t exist.
Something I forgot to mention in my last devlog is that if there was the files file1.txt and file01.txt, it (c/w)ould rename over one of the files and silently delete the file written over. I still have to fix this issue.
I’ve wanted to learn Zig, so I looked up Zig crash courses on Youtube, and this is from the one I was watching.
Hello again! I can’t believe we’re on devlog #8, considering I have been doing pretty sparse devlogs!
IT’S UNIT TEST TIIIIIME!!!
I added 13 unit tests to my big change_leading_zeros() function. They cover most, if not all, possible edge cases.
I discovered that in change_leading_zeros(), if the resulting new filename was the same as the old, would still do the rename, and it would count as a renamed file, so that the actual number of renamed files would be too large.
I realised that my get_filenames() fn, which still has no unit tests, could be swapped to an iterator structure.
old:
pub fn get_filenames(path: &Path) -> Vec<String> {
let mut names = Vec::new();
if let Ok(read_dir) = fs::read_dir(path) {
for file in read_dir.flatten() {
names.push(
file.file_name()
.into_string()
.unwrap_or("unknown name".into()),
);
}
}
names
}
pub fn get_filenames(path: &Path) -> Vec<String> {
fs::read_dir(path)
.into_iter()
.flatten()
.flatten()
.map(|f| f.file_name().into_string().unwrap_or("unknown name".into()))
.collect()
}
The two functions behave in exactly the same way, no unnecesary unwrap()s that may cause panics
See y’all next devlog!
Here’s what I’ve been working on to try to get a good, stable release.
I discovered that when doing the normalising add, the one that’s like problem-1.txt, problem-11.rs (-n 1) -> problem-01.txt, problem-11.rs (keeps the same total length of the numeric portion), it actually counts the extension characters, so the result would actually be problem-01.txt, problem-011.rs because the extensions are different lengths. Previously, the find_target() function was like this:
pub fn find_target(filenames: &Vec<String>, count: usize) -> usize {
let mut minimum = usize::MAX;
for file in filenames {
let (dot_index, cond) = check_file(file);
if cond {
continue;
}
let split_index = find_split_index(file, dot_index);
minimum = minimum.min(file[split_index..].len());
}
minimum + count
}
Now, it’s
pub fn find_target(filenames: &Vec<String>, count: usize) -> usize {
let mut minimum = usize::MAX;
let mut is_numeric: bool = false;
for file in filenames {
let (dot_index, cond) = check_file(file);
if cond {
continue;
}
let split_index = find_split_index(file, dot_index);
minimum = minimum.min(file[split_index..dot_index].len());
is_numeric = true;
}
if is_numeric { minimum + count } else { 0 }
}
Now the target isn’t the shortest number of characters in the numeric portion plus the extension, it’s just the extension.
The is_numeric variable is just there so that it doesn’t try to add count to usize::MAX, getting an overflow if there are no files with numeric suffixes.
The last step before 1.0.0 is UNIT TESTS!!!!!!
The easiest unit tests to write are ones for my functions in helpers.rs, including find_target(), check_file(), find_split_index(), and get_filenames(). I wrote 15 unit tests for the first three, and I might write some for get_filenames after I write some for change_leading_zeros(). While writing the unit tests, I stumbled upon the edge case for find_target if there are no applicable files, which is why I added the is_numeric above.
The next step is writing the aforementioned unit tests for change_leading_zeros().
Got the homebrew tap working! Not much to say, except that it is a tad annoying to have to have a separate repo just for the tap.
Because of the Homebrew tap, I decided to swap the non-nushell completions to a hidden flag, so you can run them with –generate-completions zsh/bash/fish/elvish/powershell to create the completions file, which the tap can do at install time for zsh, bash, and fish. Unfortunately, it can’t do it for elvish, powershell, or nushell, so you have to manually install completions for those three.
Because I now have a homebrew tap, installing man files is much easier. I brought back my old clap_mangen code for build.rs, and added a line to the formula, so now man generation works!
Since the failure that was clap_mangen, I have been considering a homebrew release, because then I can more easily have the man files and completions be installed, and I would do homebrew specifically since it is the main package manager I use, and it kind of works on everything.
I was using Claude Code to teach me how to create the tap, but I unfortunately ran through my limit and $100 of usage credits I got for free when Fable 5 dropped off my plan on a separate data entry task running with Claude Chrome, so I need to do some testing of the tap.
See y’all next devlog!
A few days ago, I looked up clap on crates.io, and realised there were many crates built to add functionality to CLAP, like clap_mangen, clap-version-flag, and more. The two this devlog is about are clap_complete and clap_complete_nushell. These two allow for the generation of shell completions; clap_complete supports Bash, Zsh, Fish, Elvish, and PowerShell; clap_complete_nushell adds nushell support. The standard package has a new (in-development) feature that allows for the binary itself to be the completion script, allowing for more minimal user setup if downloading from crates.io†. The nushell version does not have this feature, so you have to run it with a special flag and save the output to a completions file in ~/.config/nushell/completions/leading-zero-util.nu.
† crates.io doesn’t have an install step so it doesn’t look in the OUT_DIR at build time, so it doesn’t pick up files like the completion files. This same issue is why I chose not to use clap_mangen, since I only currently distribute through crates.io. If I later make a Homebrew tap, I’ll probably change the way the completions are done and re-add clap_mangen.
When I was setting up my build.rs to use clap_mangen, I had to separate my CLI arg parser construct from the rest of my code and put it in a new file. I also realised that some of my code in my (basically) main function (change_leading_zeros()) was repetitive and could be extracted into functions.
My change_leading_zeros() was extremely long and deserved its own file. Even after moving a ton of code into helpers.rs, it’s still 78 lines long, thoroughly violating Uncle Bob’s standards. (Granted, his standards are bad, and Uncle Bob could go trip on a log for all I care)
I have never worked in a multi-file project before, not even for my Pong game, which is just 747 lines of code all in one file, and it could have easily been broken up into more files. I have a really fast scrolling speed on my laptop, to the point of it being almost unusable, so scrolling all the code in that project was annoying. Because I have never made a multi-file Rust project before, I had no idea what I was doing. I ran into a ton of errors, including my LSP stopping working, so I didn’t have autocomplete or clippy linting for one of my files for a bit, so I got even more confused. Now, fortunately, everything works now, and I even found some issues in my code (an if condition with an OR in which the first part of the OR was redundant; a for loop that instead could have been an iterator chain; a variable that stayed mutable after mutability was unnecessary), so it was an overall great success!
Previously, I mentioned that the LZU operates off of a target length for file names, so having a schema like file01.txt project02.rs problem03.py wouldn’t work, but I was dead wrong. I haven’t worked on the project in a month and a half, and when reading over my code to see what it does, I mistook target as the target filename length, not the target length of the numeric portion. I thus removed the warnings associated with what I believed to be a behaviour of the LZU from the short help, long help, and README. The lesson is maybe to double-check with a friend or smth when you get confused by your own code.
A couple things have happened since the last devlog, so let’s go through them!
Until today, and we will get to why, all the code was in src/main.rs, which made it harder to deal with and harder to work with. I moved the Cli struct (the clap parser struct) to a new file, src/cli.rs, so it’s a little more readable now.
As I mentioned in my last devlog, I was going to use the clap_mangen crate to generate a man file for the leading-zero-util. I set it up and created a build.rs, but realised that crates.io wouldn’t do anything with the man files, so you can’t run man leading-zero-util. This was v0.4.0 (We are now on v0.5.0). The benefit of a man file is minuscule, and I can always add it back to my build.rs. It wasn’t worth the extra dependencies, so I dropped it.
I was previously considering the clap-version-flag crate for a better version message, but realised it would add basically nothing, and it isn’t well maintained. I decided instead to use shadow-rs for a better long version. I had deleted my build.rs, so I made a new one and added shadow-rs to the code.
Hello everyone!
Dusting off my leading zero utility after finishing up Pong, and there were some issues!
I realised that my utility didn’t work for filenames with extensions (no problem-01.rs or mediocre-01.py), so that needed to be fixed.
The CLI uses a target length for non-basic additions, so it only works on filenames with nonnumeric sections of the same length (target-01.rs, project-11.rs), but not problem-11 & target-01. Consequently, the help sections need to warn about this. I also added some more examples at the bottom of the long help.
This isn’t as much of an issue as something I just found out about. There is a crate, clap_mangen, THAT LETS YOU GENERATE MAN DOCS, which I feel I feel way more excitement than it warrants. This is the next step for this project.
See y’all next devlog!

Leading Zero Util 0.2.0 is complete!