TL;DR: Rebuilding go-tool-base in Rust turned out to be the cleanest design review I’ve ever done. Porting a framework to a language with different idioms forces a separation: the parts that survive the move are design, and the parts that don’t are idiom. Every Go mechanism in go-tool-base had a Rust replacement, and the outcome each one produced came through unchanged. That’s the line between a decision and a habit.
Two columns
When you port a system between languages that don’t share idioms, every piece of it sorts into one of two columns without you having to decide.
In the first column is the outcome a piece of the design produces: every command receives the framework’s services, configuration is layered with a fixed precedence, commands register themselves, errors carry guidance to the user. In the second column is the mechanism that produced that outcome in the original language.
Things in the first column survive the port. You rebuild them, differently, because the tool genuinely needs them. Things in the second column do not survive. You find their replacement, and the Go version turns out to have been one valid implementation of an idea, not the idea itself. Doing this for go-tool-base, mechanism by mechanism, was more honest about my design than any amount of staring at it would have been.
The container
Go-tool-base hands every command a Props struct. It carries the logger, the config, the assets, the filesystem handle. Some of it is reached through loosely-typed accessors. It works well, and I wrote a whole post about it.
The outcome is column one: a command should receive one object, and that object should carry the framework’s services so the command doesn’t go assembling them itself. That survived. RTB hands every command an App.
The loosely-typed accessors were column two. In Rust an App is a plain struct with concrete fields, each one an Arc<T> so a clone is a few atomic increments rather than a deep copy. Nothing is keyed by string. Nothing is fetched by name and asserted to a type. The thing the container is for survived; the way Go expressed it did not.
Registration
A go-tool-base command self-registers using a package-level init() function, which Go runs before main() and which appends the command to a global slice.
The outcome, column one, is that a command lives in its own file and inserts itself into the framework with no central list to edit. That is genuinely worth keeping.
The init() mechanism is column two, and Rust doesn’t even offer it: Rust deliberately has no code that runs before main(). The replacement is link-time registration through distributed slices, which gets its own post next. Same outcome, no global mutable state, assembled by the linker rather than a startup phase.
Configuration
Go-tool-base layers configuration with a precedence: flags over environment over file over defaults. Some of it is read back through key lookups.
The layering and the precedence are column one. They survived exactly. RTB layers config with the same ordering.
The key lookups were column two. In Rust the merged configuration is deserialised into your own serde struct, so a config value is a typed field you access like any other field, and a typo is a compile error instead of a missing key at runtime. The precedence survived; reading values back out of a string-keyed bag did not.
The error path
Go-tool-base routes every error through one handler so presentation is consistent, which I also wrote up.
One consistent exit for errors is column one. It survived. What didn’t survive was the handler: RTB has no error-handler object at all, because Rust’s own return-from-main convention plus a report hook does the job the handler was built to do. That one has its own post too.
What the exercise was actually worth
Every mechanism told the same story. The container, the registration, the config access, the error path, the cancellation signal that go-tool-base carries on a context.Context and RTB carries on a CancellationToken. In every case the thing it achieved walked across to Rust untouched, and the Go code that achieved it was left behind.
That is the useful result. Before this port I could not have told you, for any given pattern in go-tool-base, whether it was load-bearing design or just the idiomatic Go way to write that day. Now I can, because each one was forced to prove itself by being rebuilt from nothing in a language that wouldn’t accept the original. Whatever survived was real. Whatever I had to replace was always replaceable, which means it was never the point.
The upshot
Porting a framework into a language with different idioms separates design from habit for free. The outcome a pattern produces is design, and it survives the move. The mechanism that produced it is idiom, and it gets left behind for the new language’s equivalent.
Go-tool-base’s Props bag, its init() registration, its key-based config access and its error handler were all idiom. The single context object, self-registration, layered precedence and a consistent error exit were all design, and all four came through to RTB intact. The next three posts take the most interesting replacements one at a time, starting with how a Rust command registers itself when the language won’t run anything before main.