<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Rust on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/rust/</link><description>Recent content in Rust on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Fri, 17 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/rust/index.xml" rel="self" type="application/rss+xml"/><item><title>The scaffolder that won't hand you code that doesn't compile</title><link>https://phpboyscout.uk/the-scaffolder-that-wont-hand-you-code-that-doesnt-compile/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-scaffolder-that-wont-hand-you-code-that-doesnt-compile/</guid><description>&lt;img src="https://phpboyscout.uk/the-scaffolder-that-wont-hand-you-code-that-doesnt-compile/cover-the-scaffolder-that-wont-hand-you-code-that-doesnt-compile.png" alt="Featured image of post The scaffolder that won't hand you code that doesn't compile" /&gt;&lt;p&gt;Most scaffolders hand you a stub. You run the generate command, you get a file with the right shape and a &lt;code&gt;// TODO: implement&lt;/code&gt; where the actual work goes, and the rest is on you. That&amp;rsquo;s fine, it&amp;rsquo;s upfront, and it&amp;rsquo;s most of what the rtb scaffolder does too: it&amp;rsquo;s the Rust port of the &lt;a class="link" href="https://phpboyscout.uk/what-survives-a-port/" &gt;go-tool-base one&lt;/a&gt;, it works from a &lt;a class="link" href="https://phpboyscout.uk/design-your-whole-cli-in-one-file/" &gt;single manifest&lt;/a&gt;, and it &lt;a class="link" href="https://phpboyscout.uk/scaffolding-that-respects-your-edits/" &gt;regenerates without clobbering the code you&amp;rsquo;ve written&lt;/a&gt;. If you want the whole tour, those three cover it.&lt;/p&gt;
&lt;p&gt;This is about the one bit they don&amp;rsquo;t, which is what happens when you ask the scaffolder to write the implementation, not just the stub.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rtb generate command --prompt &amp;quot;add a command that lists the user's pinned repos&amp;quot;&lt;/code&gt; doesn&amp;rsquo;t drop a TODO. It drafts the actual command, with AI doing the drafting. Which sounds great in a demo and is a menace in practice, because AI-drafted code that doesn&amp;rsquo;t compile is strictly worse than a stub. A stub at least admits it&amp;rsquo;s empty. A plausible-looking command that references a function that doesn&amp;rsquo;t exist, or borrows something it can&amp;rsquo;t, or compiles but fails its own test, costs you more time to discover and unpick than writing it yourself would have. The generation isn&amp;rsquo;t the hard part. Trusting the output is.&lt;/p&gt;
&lt;p&gt;Which is why the real work didn&amp;rsquo;t go into the generating at all. It went into &lt;code&gt;ai/verifier.rs&lt;/code&gt;, a repair loop wrapped around the one grader in the Rust ecosystem that cannot be sweet-talked: cargo.&lt;/p&gt;
&lt;p&gt;It runs in stages, and each stage is a gate. &lt;code&gt;cargo check&lt;/code&gt;: does it compile? If not, the compiler&amp;rsquo;s error goes straight back to the AI as the next instruction, and it tries again. Then &lt;code&gt;cargo clippy&lt;/code&gt;: is it idiomatic, does it trip any lints? Same deal, the lint output is the prompt for the next pass. Then &lt;code&gt;cargo test&lt;/code&gt;: does it actually do the thing? A failing test is just another error to feed back. The loop only stops when all three are green, or when it hits &lt;code&gt;--max-repair-iterations&lt;/code&gt; and gives up and tells you, rather than handing you something broken with a confident smile.&lt;/p&gt;
&lt;p&gt;That cap is there on purpose, by the way. The loop is bounded on purpose, because AI repair loops have a lovely failure mode where they churn forever, each pass convinced it&amp;rsquo;s nearly there, burning tokens and time on something that was never going to converge. So there&amp;rsquo;s a cap, and when the cap is reached the scaffolder stops, tells you it couldn&amp;rsquo;t get there, and leaves you the last attempt plus the errors. That&amp;rsquo;s a far better outcome than the alternative, which is a tool that either loops to infinity or ships code that doesn&amp;rsquo;t build.&lt;/p&gt;
&lt;p&gt;The bit I&amp;rsquo;m most attached to, though, is &lt;code&gt;--agentless&lt;/code&gt;. It short-circuits the whole AI path. No drafting, no repair loop, no model in the chain at all, just the deterministic templating you&amp;rsquo;d get from any other scaffolder. And I built that in deliberately, because the moment an AI step becomes load-bearing in a tool other people depend on, you&amp;rsquo;ve added a requirement that there&amp;rsquo;s a model available, a network, an API key, and a budget. Sometimes you want none of that. CI that has to be reproducible. An offline machine. A change where you simply don&amp;rsquo;t want a model anywhere near the diff. &lt;code&gt;--agentless&lt;/code&gt; keeps the AI an accelerator rather than a dependency, and a tool that can&amp;rsquo;t run without a model is a tool with a single point of failure you didn&amp;rsquo;t choose.&lt;/p&gt;
&lt;p&gt;Generating the code is the cheap bit, and verifying it is the actual product. Anyone can wire a model up to write a Rust function. What makes it a tool and not a party trick is that nothing it writes gets anywhere near your project until &lt;code&gt;cargo&lt;/code&gt; has had its say. The AI writes the first draft. Cargo decides whether you ever see it.&lt;/p&gt;</description></item><item><title>Which CLI library should you start with?</title><link>https://phpboyscout.uk/which-cli-library-should-you-start-with/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/which-cli-library-should-you-start-with/</guid><description>&lt;img src="https://phpboyscout.uk/which-cli-library-should-you-start-with/cover-which-cli-library-should-you-start-with.png" alt="Featured image of post Which CLI library should you start with?" /&gt;&lt;p&gt;You&amp;rsquo;ve decided to build a command-line tool. Good. And before you&amp;rsquo;ve written a single line of the thing it&amp;rsquo;s actually meant to do, you hit a decision that feels far more permanent than it should: which library parses the arguments. You go looking for the answer, and instead of an answer&amp;hellip; you get a scrap. The Go crowd will tell you &lt;code&gt;flag&lt;/code&gt;, or Cobra, or urfave/cli, usually with a bit of heat. The Rust crowd will tell you clap, and then argue about how you ought to hold it.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve shipped CLIs in both languages, and wired both into frameworks other people build on (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base" target="_blank" rel="noopener"
 &gt;go-tool-base&lt;/a&gt; and &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base" target="_blank" rel="noopener"
 &gt;rust-tool-base&lt;/a&gt;). So here&amp;rsquo;s the map I wish I&amp;rsquo;d had. Not &amp;ldquo;the best one&amp;rdquo;, because there isn&amp;rsquo;t one, but where to &lt;em&gt;start&lt;/em&gt;, and the single thing that tells you when you&amp;rsquo;ve outgrown it.&lt;/p&gt;
&lt;h2 id="go-a-ladder-you-climb"&gt;Go: a ladder you climb
&lt;/h2&gt;&lt;p&gt;Go hands you a perfectly good argument parser in the standard library, &lt;code&gt;flag&lt;/code&gt;, and for a great many tools that is the entire answer. A couple of options, no subcommands, done:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;world&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;who to greet&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;hello, %s\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No dependency, no ceremony, and it&amp;rsquo;s already on your machine. If your tool is one verb and a handful of flags, stop reading and use this. Reaching for anything bigger is just wiring you&amp;rsquo;ll maintain for nobody&amp;rsquo;s benefit.&lt;/p&gt;
&lt;p&gt;The moment &lt;code&gt;flag&lt;/code&gt; runs out of road is a specific one, and it pays to know exactly where it sits&amp;hellip; &lt;strong&gt;nested subcommands.&lt;/strong&gt; The instant you want &lt;code&gt;mytool build&lt;/code&gt; and &lt;code&gt;mytool deploy&lt;/code&gt; to be separate commands, each with its own flags and its own &lt;code&gt;--help&lt;/code&gt;, &lt;code&gt;flag&lt;/code&gt; has nothing for you and you&amp;rsquo;re hand-rolling a tangle of switch statements. That&amp;rsquo;s the signal to climb.&lt;/p&gt;
&lt;p&gt;At the top of the ladder is &lt;a class="link" href="https://cobra.dev/" target="_blank" rel="noopener"
 &gt;Cobra&lt;/a&gt;. It&amp;rsquo;s the one behind kubectl, docker, hugo, gh and helm, north of 170,000 projects all told, so if you&amp;rsquo;ve ever used a Go CLI you&amp;rsquo;ve used it. Subcommands, generated help, shell completions, man pages: all the furniture a &amp;ldquo;grown-up&amp;rdquo; tool eventually wants. The cost is ceremony. A &lt;code&gt;cobra.Command&lt;/code&gt; per verb, usually &lt;a class="link" href="https://github.com/spf13/viper" target="_blank" rel="noopener"
 &gt;Viper&lt;/a&gt; alongside it for config, and a fair pile of boilerplate before the tool does anything at all.&lt;/p&gt;
&lt;p&gt;Between the two sits &lt;a class="link" href="https://cli.urfave.org/" target="_blank" rel="noopener"
 &gt;urfave/cli&lt;/a&gt;, the option people forget to mention in the fight. It does subcommands like Cobra, but with a lighter, more declarative feel, and if Cobra&amp;rsquo;s structure looks like more than you need, it&amp;rsquo;s the sensible middle rung.&lt;/p&gt;
&lt;p&gt;So Go is a genuine ladder: start on &lt;code&gt;flag&lt;/code&gt;, and climb to a framework the day subcommands arrive. Or don&amp;rsquo;t wire Cobra and Viper together yourself at all. That assembly is precisely the job &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/f93f6b4/go.mod#L39-L41" target="_blank" rel="noopener"
 &gt;go-tool-base wraps up for you&lt;/a&gt;, which is &lt;a class="link" href="https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-1/" &gt;a whole series of its own&lt;/a&gt;. A funnel, I&amp;rsquo;ll grant you, but a fair one.&lt;/p&gt;
&lt;h2 id="rust-a-ladder-you-descend"&gt;Rust: a ladder you descend
&lt;/h2&gt;&lt;p&gt;Rust is a different shape entirely, and that difference is the interesting part of this whole question.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s no argument parser in Rust&amp;rsquo;s standard library, and there&amp;rsquo;s no real fight over which crate to use either, because one of them won and won decisively. &lt;a class="link" href="https://docs.rs/clap/latest/clap/" target="_blank" rel="noopener"
 &gt;clap&lt;/a&gt; has getting on for a &lt;em&gt;billion&lt;/em&gt; downloads and is a dependency of some sixty-odd thousand other crates. It&amp;rsquo;s the default in the truest sense of the word: it&amp;rsquo;s what people reach for without stopping to think, and it scales from a single flag to a deep tree of subcommands without you ever swapping libraries. One struct, subcommands and all:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[derive(Parser)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;Cli&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;#[command(subcommand)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;: &lt;span class="nc"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[derive(Subcommand)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Build&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;: &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Deploy&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;#[arg(long)]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dry_run&lt;/span&gt;: &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s the &lt;code&gt;derive&lt;/code&gt; style, macros hung on your structs, and it&amp;rsquo;s the ergonomic default (there&amp;rsquo;s a runtime &lt;code&gt;builder&lt;/code&gt; API too, for when you&amp;rsquo;re assembling commands dynamically). It&amp;rsquo;s what we use in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/5ba936b/Cargo.toml#L64" target="_blank" rel="noopener"
 &gt;rust-tool-base&lt;/a&gt;, and the reasoning was nothing exotic. We already knew CLI conventions well, we knew rtb needed the configuration depth only a heavy-hitter gives you, and clap&amp;rsquo;s sheer popularity made it the boring, sensible bet. When a crate is that dominant, choosing it is the low-risk move, and rtb goes a step further and &lt;em&gt;generates&lt;/em&gt; the clap wiring so you never write that struct by hand at all.&lt;/p&gt;
&lt;p&gt;Which is why the Rust ladder runs the other way. In Go you start small and climb up toward a framework. In Rust the framework &lt;em&gt;is&lt;/em&gt; the ground floor, and the only real question is whether some specific pressure justifies stepping &lt;em&gt;down&lt;/em&gt; to something smaller:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a class="link" href="https://crates.io/crates/pico-args" target="_blank" rel="noopener"
 &gt;pico-args&lt;/a&gt;&lt;/strong&gt; or &lt;strong&gt;&lt;a class="link" href="https://crates.io/crates/lexopt" target="_blank" rel="noopener"
 &gt;lexopt&lt;/a&gt;&lt;/strong&gt; when binary size and compile time genuinely matter and you can live without generated help. Zero or near-zero dependencies, and you write the usage text yourself. Grand for a small, sharp tool.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a class="link" href="https://crates.io/crates/argh" target="_blank" rel="noopener"
 &gt;argh&lt;/a&gt;&lt;/strong&gt; when you want the derive ergonomics but lighter than clap, and you don&amp;rsquo;t mind that it follows Fuchsia&amp;rsquo;s conventions rather than the Unix ones your users will expect.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a class="link" href="https://crates.io/crates/bpaf" target="_blank" rel="noopener"
 &gt;bpaf&lt;/a&gt;&lt;/strong&gt; when your parsing is genuinely unusual and you&amp;rsquo;d sooner compose it from combinators, while keeping the niceties like completions.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every one of those is a step down from clap for a reason you can actually name. Without such a reason, clap is the answer, and that isn&amp;rsquo;t laziness. It&amp;rsquo;s the ecosystem having already done the choosing on your behalf.&lt;/p&gt;
&lt;p&gt;(One wrinkle worth a footnote: clap&amp;rsquo;s &amp;ldquo;global&amp;rdquo; flags don&amp;rsquo;t behave the way you&amp;rsquo;d assume once you start nesting commands, which cost me an afternoon and &lt;a class="link" href="https://phpboyscout.uk/claps-global-flag-except-in-a-passthrough-subtree/" &gt;a post of its own&lt;/a&gt;.)&lt;/p&gt;
&lt;h2 id="same-rule-opposite-ends"&gt;Same rule, opposite ends
&lt;/h2&gt;&lt;p&gt;Strip the two languages back and the principle underneath is identical: &lt;strong&gt;use the smallest thing that fits your constraints, and let a real need, not a hunch, be the thing that moves you off it.&lt;/strong&gt; What&amp;rsquo;s genuinely interesting is that the two ecosystems put the &lt;em&gt;default&lt;/em&gt; at opposite ends of that rule.&lt;/p&gt;
&lt;p&gt;Go&amp;rsquo;s default sits at the bottom, because the standard library gives you &lt;code&gt;flag&lt;/code&gt; for nothing and moving up to Cobra is a switch you make on purpose. Rust&amp;rsquo;s default sits at the top, because the standard library gives you nothing at all and one crate grew so dominant that starting anywhere else is the choice you&amp;rsquo;d have to justify. Go makes you pick and grow into it. Rust makes you descend under pressure.&lt;/p&gt;
&lt;p&gt;So the real answer to &amp;ldquo;which should I start with&amp;rdquo; isn&amp;rsquo;t a library. It&amp;rsquo;s a direction. In Go, start at the bottom and climb the day the subcommands turn up. In Rust, start at the top and only ever climb &lt;em&gt;down&lt;/em&gt;, and only when something forces your hand. Either way, you&amp;rsquo;ll know the exact moment you need to move, which is the only thing the question was ever really asking.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Cover: the &lt;a class="link" href="https://go.dev/blog/gopher" target="_blank" rel="noopener"
 &gt;Go gopher&lt;/a&gt; by Renée French (&lt;a class="link" href="https://creativecommons.org/licenses/by/3.0/" target="_blank" rel="noopener"
 &gt;CC BY 3.0&lt;/a&gt;) and &lt;a class="link" href="https://rustacean.net/" target="_blank" rel="noopener"
 &gt;Ferris&lt;/a&gt; (CC0), reimagined in cut paper.&lt;/em&gt;&lt;/p&gt;</description></item><item><title>A flag is not a setting</title><link>https://phpboyscout.uk/a-flag-is-not-a-setting/</link><pubDate>Sun, 28 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-flag-is-not-a-setting/</guid><description>&lt;img src="https://phpboyscout.uk/a-flag-is-not-a-setting/cover-a-flag-is-not-a-setting.png" alt="Featured image of post A flag is not a setting" /&gt;&lt;p&gt;I was reviewing a change to rust-tool-base&amp;rsquo;s scaffolder when a word stopped me dead. &lt;code&gt;rtb generate config-field&lt;/code&gt;. I couldn&amp;rsquo;t have told you why in that first second&amp;hellip; I looked at it and just knew it was wrong.&lt;/p&gt;
&lt;p&gt;The verb there is &lt;code&gt;generate&lt;/code&gt;, and the verb is fine. It was the &lt;em&gt;noun&lt;/em&gt; that grated, &lt;code&gt;config-field&lt;/code&gt;, the name of the thing being made. Renaming it is a small change. It&amp;rsquo;s also a &lt;em&gt;breaking&lt;/em&gt; one, and a gut feeling is no reason to break someone&amp;rsquo;s command, so before I touched it I went and worked out what the instinct was reacting to.&lt;/p&gt;
&lt;h2 id="accurate-and-still-wrong"&gt;Accurate, and still wrong
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the awkward bit: &lt;code&gt;config-field&lt;/code&gt; is correct. The thing it makes really is a field on a config struct. If that name lived deep in a package, somewhere only another developer reading the source would ever trip over it, it&amp;rsquo;d be fine. The code&amp;rsquo;s audience is me, and &amp;ldquo;config field&amp;rdquo; is exactly what the code sees.&lt;/p&gt;
&lt;p&gt;But it doesn&amp;rsquo;t live deep in a package. It sits right out on the command line, on the one surface a user actually types, and a name out there has a different job. It has to telegraph what it does to someone who has never read a line of the source, in words a layperson would reach for. By that test &lt;code&gt;config-field&lt;/code&gt; fails, and not because it&amp;rsquo;s wrong. It fails because it&amp;rsquo;s right about the wrong thing. It describes the plumbing when all the user wants is to turn on the tap.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the rule I keep coming back to anywhere a person actually touches the tool: accurate is the floor, not the bar.&lt;/p&gt;
&lt;h2 id="what-the-noun-names"&gt;What the noun names
&lt;/h2&gt;&lt;p&gt;The right name falls out of what the thing actually is, so I went and pinned that down. rtb&amp;rsquo;s scaffolder makes three different things, and the noun is how you pick which:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript3" data-lang="gdscript3"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;rtb&lt;/span&gt; &lt;span class="n"&gt;generate&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;# a new subcommand&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;rtb&lt;/span&gt; &lt;span class="n"&gt;generate&lt;/span&gt; &lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;# a command-line argument on a command&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;rtb&lt;/span&gt; &lt;span class="n"&gt;generate&lt;/span&gt; &lt;span class="n"&gt;setting&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;# a field on the tool&amp;#39;s typed config&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A flag and a setting sound like cousins, but they answer two different questions: &lt;em&gt;where does the value come from&lt;/em&gt;, and &lt;em&gt;how long does it live&lt;/em&gt;. A flag is something the user types for a single run (a &lt;code&gt;clap&lt;/code&gt; argument, in Rust terms), like &lt;code&gt;deploy --region eu&lt;/code&gt; or &lt;code&gt;--dry-run&lt;/code&gt;. Transient, scoped to the one command. A setting is a typed field on the tool&amp;rsquo;s &lt;code&gt;AppConfig&lt;/code&gt;, read from its layered config: a file, the environment, or a one-off override on the CLI. Persistent, and tool-wide. (&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/eb13cd9/docs/concepts/flags-vs-settings.md" target="_blank" rel="noopener"
 &gt;The full contrast is its own doc now&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;Put the two side by side and the old name gives itself away. &lt;code&gt;flag&lt;/code&gt; says what the thing is &lt;em&gt;to a user&lt;/em&gt;. &lt;code&gt;config-field&lt;/code&gt; said what it is &lt;em&gt;to the code&lt;/em&gt;. One tells the truth at the surface; the other leaks an implementation detail you were never meant to care about.&lt;/p&gt;
&lt;h2 id="why-theyre-two-things-at-all"&gt;Why they&amp;rsquo;re two things at all
&lt;/h2&gt;&lt;p&gt;This is the bit that makes the rename honest rather than fussy, and it&amp;rsquo;s where rust-tool-base and go-tool-base part ways.&lt;/p&gt;
&lt;p&gt;In go-tool-base, a flag and a setting are pretty much the same object. cobra and viper (Go&amp;rsquo;s CLI and config libraries) fuse them: you bind a flag, viper reads its value from a config file or the environment, and you&amp;rsquo;re done. One persistent flag laid over a config bag. That&amp;rsquo;s no compromise, it&amp;rsquo;s an excellent convenience abstraction, gtb leans on it to the hilt, and for what it&amp;rsquo;s worth it&amp;rsquo;s the model I personally find the &lt;em&gt;simpler&lt;/em&gt; of the two. One mechanism, one thing to keep in your head.&lt;/p&gt;
&lt;p&gt;Rust won&amp;rsquo;t hand you that fusion, and it&amp;rsquo;s right not to. rtb&amp;rsquo;s config is a typed &lt;code&gt;AppConfig&lt;/code&gt; (built on figment, a Rust config library), not a dynamic &lt;code&gt;get_string(&amp;quot;key&amp;quot;)&lt;/code&gt; bag, so a command-line argument and a config field genuinely are different types with different lifetimes. Splitting them isn&amp;rsquo;t rtb being puritanical about it. It&amp;rsquo;s the shape Rust&amp;rsquo;s type system gives you, and the framework leans in and makes the most of it. The rtb version is, no argument, the more type-safe of the two.&lt;/p&gt;
&lt;p&gt;So neither is better. They suit different paradigms, and both do the job beautifully. But the knock-on for naming is concrete. Once a flag and a setting really &lt;em&gt;are&lt;/em&gt; two different things, calling one of them &lt;code&gt;config-field&lt;/code&gt; doesn&amp;rsquo;t just expose the plumbing, it tells a small lie: it implies a setting is the same kind of object as the struct field it happens to sit in. &lt;code&gt;setting&lt;/code&gt; tells the truth. This is the thing you configure once and the tool remembers, the sibling of &lt;code&gt;flag&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;(rtb has form here, mind. &amp;ldquo;flag&amp;rdquo; already pulls double duty: a runtime feature flag and a &lt;a class="link" href="https://phpboyscout.uk/two-kinds-of-feature-flag/" &gt;compile-time Cargo feature&lt;/a&gt; are two &lt;em&gt;more&lt;/em&gt; genuinely different things the framework keeps deliberately apart. Stretch one word across that many concepts and naming each one precisely stops being pedantry and becomes the only way anyone keeps them straight.)&lt;/p&gt;
&lt;h2 id="the-change"&gt;The change
&lt;/h2&gt;&lt;p&gt;So &lt;code&gt;config-field&lt;/code&gt; became &lt;code&gt;setting&lt;/code&gt;, and picked up its mirror image &lt;code&gt;remove setting&lt;/code&gt; to round out the trio of &lt;code&gt;command&lt;/code&gt; / &lt;code&gt;flag&lt;/code&gt; / &lt;code&gt;setting&lt;/code&gt;. It&amp;rsquo;s a &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/commit/eb13cd9" target="_blank" rel="noopener"
 &gt;breaking change&lt;/a&gt;, &lt;code&gt;rtb generate config-field&lt;/code&gt; is gone for good, and it earned its keep. The cost is a line in a changelog. The return is a command surface that says what it means.&lt;/p&gt;
&lt;h2 id="name-the-tap"&gt;Name the tap
&lt;/h2&gt;&lt;p&gt;The gut reaction was right, but the gut reaction was never the point. The point is what it was reacting to: a name, out on a surface a human uses, describing the machinery instead of the job. &lt;code&gt;config-field&lt;/code&gt; was accurate. It still made the user stop and think about a struct field when all they wanted was to set something up and get on with it.&lt;/p&gt;
&lt;p&gt;Nobody turning on a tap wants to think about the pipework behind the wall. Name the tap.&lt;/p&gt;</description></item><item><title>Everyone wants Rust's safety, nobody wants Rust</title><link>https://phpboyscout.uk/everyone-wants-rusts-safety-nobody-wants-rust/</link><pubDate>Sun, 14 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/everyone-wants-rusts-safety-nobody-wants-rust/</guid><description>&lt;img src="https://phpboyscout.uk/everyone-wants-rusts-safety-nobody-wants-rust/cover-everyone-wants-rusts-safety-nobody-wants-rust.png" alt="Featured image of post Everyone wants Rust's safety, nobody wants Rust" /&gt;&lt;p&gt;This spring, the better part of a million lines of Zig quietly became a million
lines of Rust. Bun, the JavaScript runtime that was the showcase for &amp;ldquo;you don&amp;rsquo;t
need a borrow checker, you need good tools and a steady hand&amp;rdquo;, looked at its own
memory bugs and switched teams. Around
&lt;a class="link" href="https://www.techzine.eu/news/devops/141364/bun-takes-a-surprising-step-from-zig-to-rust/" target="_blank" rel="noopener"
 &gt;99.8% of its test suite passed&lt;/a&gt;
on the rewritten code, a clutch of memory leaks closed in the move, and the
maintainers said the quiet part out loud: the previous release would be the last
one written in Zig.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s tempting to read that as Rust winning, hoist the flag, and move on. I don&amp;rsquo;t
think that&amp;rsquo;s quite the story, and the more interesting one is happening everywhere
else at the same time.&lt;/p&gt;
&lt;p&gt;Because Bun is the exception that went all the way. Everyone else is trying to get
the &lt;em&gt;safety&lt;/em&gt; without the &lt;em&gt;Rust&lt;/em&gt;, and watching how they&amp;rsquo;re going about it tells you
more than one runtime&amp;rsquo;s heroic rewrite does.&lt;/p&gt;
&lt;h2 id="what-everyones-actually-after"&gt;What everyone&amp;rsquo;s actually after
&lt;/h2&gt;&lt;p&gt;A quick level-set, because not everyone reading this writes systems code daily.
&amp;ldquo;Memory safety&amp;rdquo; is the property that a program can&amp;rsquo;t read or write memory it has no
business touching: no using a value after you&amp;rsquo;ve freed it, no running off the end
of an array. It sounds niche. It is, by most counts, behind something like
&lt;a class="link" href="https://www.kusari.dev/blog/rust-wont-fix-everything-moving-toward-a-memory-safe-future" target="_blank" rel="noopener"
 &gt;70% of serious security vulnerabilities&lt;/a&gt;,
which is why governments and trillion-dollar companies suddenly care a great deal.&lt;/p&gt;
&lt;p&gt;There are roughly three ways to get it. Rust uses a &lt;em&gt;borrow checker&lt;/em&gt;: a compiler
that flatly refuses to build your program unless it can prove, before it ever runs,
that you never touch memory after you&amp;rsquo;re done with it. The price is that it argues
with you the entire time you&amp;rsquo;re writing. The product is that an entire category of
bug becomes literally unwriteable. Go (and most managed languages) uses a &lt;em&gt;garbage
collector&lt;/em&gt;: a runtime janitor that frees memory for you, so you mostly can&amp;rsquo;t get it
wrong, at the cost of some overhead and a little control. And then there&amp;rsquo;s the old
way, the one most code on Earth still uses: trust the developer to get it right,
and add an &lt;em&gt;escape hatch&lt;/em&gt;, usually a keyword like &lt;code&gt;unsafe&lt;/code&gt;, for the bits where they
promise they have.&lt;/p&gt;
&lt;p&gt;The retrofit trend is everyone in that third camp trying to inch toward the first
two without rewriting the world.&lt;/p&gt;
&lt;h2 id="rust-didnt-invent-any-of-this"&gt;Rust didn&amp;rsquo;t invent any of this
&lt;/h2&gt;&lt;p&gt;Worth saying plainly, because the fan club rarely does: Rust invented almost none
of it. The borrow checker is, &lt;a class="link" href="https://doc.rust-lang.org/reference/influences.html" target="_blank" rel="noopener"
 &gt;by Rust&amp;rsquo;s own admission&lt;/a&gt;,
Cyclone&amp;rsquo;s region-based memory management, from a safe-C experiment in the early
2000s, welded to &lt;a class="link" href="https://borretti.me/article/type-systems-memory-safety" target="_blank" rel="noopener"
 &gt;affine types out of linear logic&lt;/a&gt;,
ideas that predate Rust by decades. And it goes beyond the borrow checker.
Rust&amp;rsquo;s exhaustive pattern matching came from ML and Haskell. Its &amp;ldquo;errors are
values, and there is no null&amp;rdquo; approach, &lt;code&gt;Result&lt;/code&gt; and &lt;code&gt;Option&lt;/code&gt;, is Haskell&amp;rsquo;s Maybe
and Either in work boots.&lt;/p&gt;
&lt;p&gt;What Rust did, and did better than anyone before it, was taste and integration: it
curated thirty-odd years of academic research into one coherent language and proved
the ideas could carry real systems code rather than just research papers. That is
the genuine USP, and it&amp;rsquo;s why the rest of the industry is now shopping from the
same shelf. Pattern matching has landed in Python and Java, with a proposal in
flight for C++26. Swift 6 shipped
&lt;a class="link" href="https://www.infoworld.com/article/3529619/swift-6-arrives-with-improved-concurrency-data-race-safety.html" target="_blank" rel="noopener"
 &gt;compile-time data-race safety&lt;/a&gt;,
its &lt;code&gt;Sendable&lt;/code&gt; machinery a close cousin of Rust&amp;rsquo;s &lt;code&gt;Send&lt;/code&gt; and &lt;code&gt;Sync&lt;/code&gt;. The borrow
checker just gets the headlines because it&amp;rsquo;s the hardest bit to copy. Which makes
the title almost too literal: everyone wants Rust&amp;rsquo;s safety, and they are quietly
adopting its mechanisms one feature at a time.&lt;/p&gt;
&lt;h2 id="credit-where-its-due-c-is-doing-this-properly"&gt;Credit where it&amp;rsquo;s due: C# is doing this properly
&lt;/h2&gt;&lt;p&gt;The example that made me sit up is C#. In C# 16, Microsoft is
&lt;a class="link" href="https://devblogs.microsoft.com/dotnet/improving-csharp-memory-safety/" target="_blank" rel="noopener"
 &gt;redefining the &lt;code&gt;unsafe&lt;/code&gt; keyword&lt;/a&gt;
that&amp;rsquo;s been in the language since version one. Instead of &lt;code&gt;unsafe&lt;/code&gt; marking a lump
of syntax, it now marks a &lt;em&gt;contract&lt;/em&gt;: a promise the compiler can&amp;rsquo;t verify and a
human has to read and uphold, with documentation and static analysers nudging you
to take it seriously. They&amp;rsquo;re even floating badges on NuGet packages to show which
ones have opted in.&lt;/p&gt;
&lt;p&gt;My first instinct with any retrofit is suspicion, because bolting safety onto a
language after the fact has a long and miserable history, and an escape hatch that&amp;rsquo;s
easy to reach is an escape hatch people will reach for the moment they&amp;rsquo;re in a
hurry. But this isn&amp;rsquo;t a bolt-on. Taking the keyword that&amp;rsquo;s already there and giving
it real teeth is working &lt;em&gt;with&lt;/em&gt; the grain of the language instead of stapling a
second safety system alongside the first. That&amp;rsquo;s honest engineering, and it deserves
the credit. It genuinely raises the floor.&lt;/p&gt;
&lt;h2 id="a-contract-is-not-a-guarantee"&gt;A contract is not a guarantee
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s where my enthusiasm meets its limit, and it&amp;rsquo;s a distinction I happen to have
&lt;a class="link" href="https://phpboyscout.uk/forbid-means-forbid-until-linkme-needs-a-word/" &gt;a lot of skin in&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;C#&amp;rsquo;s redefined &lt;code&gt;unsafe&lt;/code&gt; makes dangerous code &lt;em&gt;visible and reviewable&lt;/em&gt;. That is a
real improvement, and most teams would be better off for it. But visible and
reviewable still means a human has to honour the promise. It&amp;rsquo;s a sign on the door.
Rust&amp;rsquo;s equivalent is a wall: in &lt;a class="link" href="https://phpboyscout.uk/rust-tool-base-the-same-idea/" &gt;rust-tool-base&lt;/a&gt;
I put &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt; at the top of all eleven shipping crates, and
&lt;code&gt;forbid&lt;/code&gt; is not advice, it&amp;rsquo;s a refusal. The compiler will not build a crate that
contains &lt;code&gt;unsafe&lt;/code&gt;, full stop, and unlike its softer sibling &lt;code&gt;deny&lt;/code&gt;, you can&amp;rsquo;t quietly
switch it back off in a corner of the code where it&amp;rsquo;s inconvenient. The whole reason
I use &lt;code&gt;forbid&lt;/code&gt; and not &lt;code&gt;deny&lt;/code&gt; is that I don&amp;rsquo;t trust future-me, in a hurry, not to
reach for the hatch.&lt;/p&gt;
&lt;p&gt;So when I look at the C# work I think: good, genuinely good, and they should take it
further. A contract a human upholds is not the same kind of thing as a proof a
compiler enforces, and the trend, if it&amp;rsquo;s serious, points at enforcement. Visible is
better than invisible. Impossible is better than visible.&lt;/p&gt;
&lt;h2 id="discipline-never-scaled-and-thats-not-an-insult"&gt;Discipline never scaled, and that&amp;rsquo;s not an insult
&lt;/h2&gt;&lt;p&gt;The objection I keep hearing, and that a younger me would have made, is that any
language can be memory-safe if you&amp;rsquo;re just disciplined enough. And it&amp;rsquo;s true, in the
way that any house can be tidy if you never get busy. In the before times we shipped
memory-safe C with code review and valgrind and sheer bloody-mindedness, and it
worked, sort of, at small scale.&lt;/p&gt;
&lt;p&gt;It doesn&amp;rsquo;t scale, and Bun is the proof sitting on the table. That wasn&amp;rsquo;t a sloppy
team learning the basics. It was a strong team, betting publicly on the
discipline-and-good-tools model, and the memory bugs piled up anyway until the
honest move was to let a compiler take the job. Discipline failing at scale isn&amp;rsquo;t a
moral failure of the engineers. It&amp;rsquo;s just what happens when you ask humans to hold a
thousand invariants in their heads across a million lines. Delegating that to a
machine that never gets tired or rushed isn&amp;rsquo;t laziness. It&amp;rsquo;s the entire point of
having compilers at all.&lt;/p&gt;
&lt;h2 id="the-part-that-changed-my-mind"&gt;The part that changed my mind
&lt;/h2&gt;&lt;p&gt;I learned most of my Rust by building rust-tool-base with an AI alongside me,
leaning on it to explain the borrow checker, suggest the idiomatic shape, and check
my work. And somewhere in that I noticed the thing I now can&amp;rsquo;t unsee: &lt;strong&gt;the borrow
checker is exactly as good a guardrail for the AI as it is for me.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A model, like a tired human, will write a confident use-after-free without blinking.
In Rust it simply doesn&amp;rsquo;t compile, so the mistake never reaches me. What that does
is move the whole error surface. The bugs that survive into review aren&amp;rsquo;t memory
bugs or lifetime bugs or data races, the language has eaten those, they&amp;rsquo;re errors of
&lt;em&gt;logic&lt;/em&gt;: the code is safe and wrong. And logic is precisely where I want my
attention, and the AI&amp;rsquo;s, because it&amp;rsquo;s the part a human has to own and the part the
models are getting better at every month. (I split my AI work across a few providers
for their different strengths, so this is not a pitch for anyone&amp;rsquo;s logo. The effect
is the same whoever&amp;rsquo;s doing the typing.)&lt;/p&gt;
&lt;p&gt;Which dissolves the one argument that ever really kept people out of Rust. &amp;ldquo;The
borrow checker is too much friction&amp;rdquo; was always the case for the defence. But Bun&amp;rsquo;s
million-line rewrite was done largely &lt;em&gt;with&lt;/em&gt; an AI, because an AI is very good at
paying a tax that is tedious and mechanical rather than creative. The friction is
getting cheaper to pay at exactly the moment the guarantee is getting more valuable
to have. In an AI-assisted world, a language that &lt;em&gt;proves&lt;/em&gt; safety is worth more, not
less, because it fences in the machine&amp;rsquo;s mistakes as firmly as your own.&lt;/p&gt;
&lt;h2 id="none-of-this-means-rewrite-everything-in-rust"&gt;None of this means rewrite everything in Rust
&lt;/h2&gt;&lt;p&gt;I want to be careful not to land somewhere smug, because most software does not need
what Rust offers and pretending otherwise is how you end up rewriting a CRUD app
nobody asked you to. Garbage collection is not a failure state. Go&amp;rsquo;s collector keeps
getting &lt;a class="link" href="https://go.dev/doc/go1.26" target="_blank" rel="noopener"
 &gt;meaningfully better&lt;/a&gt;, my own go-tool-base is GC&amp;rsquo;d
top to bottom and I have never once wished it weren&amp;rsquo;t, and &amp;ldquo;safe-by-default with a
GC&amp;rdquo; is the right answer for a vast amount of the work most of us do. The borrow
checker is a price, and you should only pay it when the thing you&amp;rsquo;re buying, that
last class of guarantee with no runtime cost, is something your stakes actually
need.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;The question was never &amp;ldquo;is it as safe as Rust&amp;rdquo;. That framing turns everything into a
loss for everyone who isn&amp;rsquo;t Rust, which is silly. The useful question is: &lt;em&gt;what does
your language make the default, and how hard does it make the escape hatch to reach?&lt;/em&gt;
Go makes safety the default and charges you a GC. Rust makes it the default and
charges you the borrow checker. C# is moving its default in the right direction and,
for now, leaves the hatch as a promise rather than a wall.&lt;/p&gt;
&lt;p&gt;Credit the retrofits, they are raising the floor for an enormous amount of code that
was never going to be rewritten. Just don&amp;rsquo;t mistake the floor for the ceiling, or a
contract a human signs for a guarantee a compiler keeps. Everyone wants Rust&amp;rsquo;s
safety, and the interesting question, now that an AI will pay the toll for you, is
who still has a reason not to want it.&lt;/p&gt;
&lt;p&gt;Widen the lens past Rust, though, because that&amp;rsquo;s where the news gets genuinely good.
We&amp;rsquo;re at a turn in how languages evolve. Compile-time rigour is spreading rather
than retreating: borrow checking is reaching the Python family through Mojo, static
typing long since conquered JavaScript, and even the managed languages are turning
their escape hatches into something you have to argue with. More of our safety is
quietly moving from &amp;ldquo;remember to&amp;rdquo; into &amp;ldquo;can&amp;rsquo;t not&amp;rdquo;. And the one thing that always
made the strict path hard to start down, the friction, is being absorbed by an AI
that will happily learn the rules so you can lean on them. I&amp;rsquo;ve been at this long
enough to distrust a rosy forecast, but I&amp;rsquo;ll put my name to this one: the outlook
for software that&amp;rsquo;s safe and secure by default has never looked better.&lt;/p&gt;</description></item><item><title>Three traps release-plz sets for a Rust workspace</title><link>https://phpboyscout.uk/three-traps-release-plz-workspace/</link><pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/three-traps-release-plz-workspace/</guid><description>&lt;img src="https://phpboyscout.uk/three-traps-release-plz-workspace/cover-three-traps-release-plz-workspace.png" alt="Featured image of post Three traps release-plz sets for a Rust workspace" /&gt;&lt;p&gt;I wrote up the two days I lost releasing a seventeen-crate workspace to crates.io
as &lt;a class="link" href="https://phpboyscout.uk/same-config-two-answers/" &gt;a war story&lt;/a&gt;, wrong
turns and all. This is the other half: the field guide, so you don&amp;rsquo;t have to lose
the same two days.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://release-plz.dev" target="_blank" rel="noopener"
 &gt;release-plz&lt;/a&gt; is a genuinely good tool, and none of what
follows is a bug. It&amp;rsquo;s three behaviours that are entirely within its design and
will still ambush you the moment you point it at a Cargo &lt;em&gt;workspace&lt;/em&gt; rather than a
single crate. Mildest first, because the third is the one that actually ate my
release.&lt;/p&gt;
&lt;h2 id="first-what-release-plz-is-doing"&gt;First, what release-plz is doing
&lt;/h2&gt;&lt;p&gt;In one line: it&amp;rsquo;s release-please for cargo. It keeps a Release MR open, bumps your
versions and per-crate changelogs from your Conventional Commits, and when that MR
merges it publishes every crate to crates.io and tags the release. On a workspace
where N crates all share one version, &amp;ldquo;the release&amp;rdquo; is N publishes and N tag
operations. Hold on to that N. It&amp;rsquo;s hiding behind all three traps.&lt;/p&gt;
&lt;h2 id="trap-1-the-default-tag-template-is-built-for-one-crate-not-a-workspace"&gt;Trap 1: the default tag template is built for one crate, not a workspace
&lt;/h2&gt;&lt;p&gt;You will reach for one tag per version, and for me it was more than tidiness. I
wanted to ship the whole framework as a single release: one &lt;code&gt;v0.5.1&lt;/code&gt; covering all
seventeen crates, because that was the compatibility promise I wanted to make.
Use the crates that share a version and they&amp;rsquo;re guaranteed to work together. A
single tag felt like the natural way to say &amp;ldquo;this is one coherent release of the
whole thing&amp;rdquo; (and it didn&amp;rsquo;t hurt that the repo already had a &lt;code&gt;v0.5.0&lt;/code&gt; tag from
before release-plz, so one unified tag also looked like continuity). So you either
set this, or, worse, you leave &lt;code&gt;git_tag_name&lt;/code&gt; unset assuming the default does
something workspace-aware:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here&amp;rsquo;s the catch. release-plz&amp;rsquo;s default &lt;code&gt;git_tag_name&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; &lt;code&gt;v{{ version }}&lt;/code&gt;, and
release-plz tags &lt;strong&gt;per crate&lt;/strong&gt;. So the first crate publishes and creates the tag
&lt;code&gt;v0.5.1&lt;/code&gt;. The second crate publishes and tries to create &lt;code&gt;v0.5.1&lt;/code&gt; again:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR failed to create git tag &amp;#39;v0.5.1&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;message&amp;#34;: &amp;#34;Tag v0.5.1 already exists&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;By the time you read that error, the first crate (and on a retry, the next, and
the next) is already live on crates.io, and crates.io publishes are forever.
Leaving the line out doesn&amp;rsquo;t save you, because the default is the same
single-crate-shaped template. This is the trap I walked straight into on the
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/f6de975/release-plz.toml#L20-L21" target="_blank" rel="noopener"
 &gt;release commit&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="trap-2-one-release-for-the-whole-workspace-isnt-a-setting-its-a-category-error"&gt;Trap 2: &amp;ldquo;one release for the whole workspace&amp;rdquo; isn&amp;rsquo;t a setting, it&amp;rsquo;s a category error
&lt;/h2&gt;&lt;p&gt;The natural next thought is &amp;ldquo;fine, I&amp;rsquo;ll keep one tag but configure release-plz to
roll the crates into a single release.&amp;rdquo; There&amp;rsquo;s no knob for that, and chasing one
is a waste of an afternoon. release-plz&amp;rsquo;s model is per-crate all the way down:
per-crate tags, per-crate GitLab/GitHub releases, per-crate changelogs. &amp;ldquo;One
unified release for the whole workspace&amp;rdquo; isn&amp;rsquo;t an option it withholds, it&amp;rsquo;s a
shape it doesn&amp;rsquo;t have.&lt;/p&gt;
&lt;p&gt;So you stop fighting it and
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/7afc42e/release-plz.toml#L21-L22" target="_blank" rel="noopener"
 &gt;set the per-crate templates explicitly&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }}-v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_release_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }} v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now each crate gets its own tag (&lt;code&gt;rtb-assets-v0.5.1&lt;/code&gt;, &lt;code&gt;rtb-config-v0.5.1&lt;/code&gt;, and so
on) and its own release. It&amp;rsquo;s more objects per version than you wanted, but it&amp;rsquo;s
the grain the tool works in, and once you accept that the collisions stop.&lt;/p&gt;
&lt;p&gt;This is where I had to pull apart two things I&amp;rsquo;d quietly merged in my head: the
version and the tag. The compatibility promise I cared about, that crates sharing
a version work together, is carried by the &lt;em&gt;version&lt;/em&gt;, and release-plz keeps every
crate on the one workspace version no matter how it tags them. The tag is just a
label pointing at a commit. I&amp;rsquo;d wanted a single tag to mean &amp;ldquo;one coherent
framework release&amp;rdquo;, but the coherence was always in the shared version number, not
in the tag. Once that landed, seventeen tags stopped feeling like seventeen
releases of seventeen different things and started looking like what they are:
seventeen labels on one versioned release. The version is not the tag. If you still want
one human-facing narrative for the whole thing, keep a hand-written root
&lt;code&gt;CHANGELOG.md&lt;/code&gt; alongside the generated per-crate ones, rather than trying to make
release-plz aggregate.&lt;/p&gt;
&lt;h2 id="trap-3-a-release-reads-its-config-from-the-release-commit-not-head"&gt;Trap 3: a release reads its config from the release commit, not HEAD
&lt;/h2&gt;&lt;p&gt;This is the small one, and the one that cost me the most, because it makes the fix
for Trap 1 look like it isn&amp;rsquo;t working.&lt;/p&gt;
&lt;p&gt;When release-plz runs a &lt;code&gt;release&lt;/code&gt;, it does not read &lt;code&gt;release-plz.toml&lt;/code&gt; from your
working tree. It reads it from the &lt;strong&gt;release commit&lt;/strong&gt;, the commit that first
introduced the version it&amp;rsquo;s releasing. So picture the obvious recovery: you hit
the tag collision, you realise your template is wrong, you fix it in a follow-up
commit and push to main. Your fix is real. It&amp;rsquo;s committed. It&amp;rsquo;s on the default
branch. And it is completely ignored, because the version hasn&amp;rsquo;t changed, so the
release commit release-plz reads from is still the old one with the old template.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t take this on faith. With the corrected per-crate template sitting on
&lt;code&gt;HEAD&lt;/code&gt;, the CI release job still tried to create the unified tag, pinned to the
&lt;em&gt;old&lt;/em&gt; commit:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR failed to create git tag &amp;#39;v0.5.1&amp;#39; with ref &amp;#39;f6de975...&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;message&amp;#34;: &amp;#34;Tag v0.5.1 already exists&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That &lt;code&gt;ref&lt;/code&gt; is the release commit, not the HEAD that held my fix. And the cruel
part: &lt;code&gt;release-plz release --dry-run&lt;/code&gt; on your laptop reads your &lt;em&gt;working-directory&lt;/em&gt;
config, so it renders the shiny new per-crate tags and tells you you&amp;rsquo;re sorted. CI
runs the real thing against the release commit and does something else entirely.
Same config file, two different answers depending on who&amp;rsquo;s asking, which is why
the war story has &lt;a class="link" href="https://phpboyscout.uk/same-config-two-answers/" &gt;the title it does&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The operational rule that falls out of this: &lt;strong&gt;any release-plz config change that
affects how a release behaves has to ride along with a version bump, or it does
not apply.&lt;/strong&gt; A &amp;ldquo;fix-up&amp;rdquo; commit on its own is a no-op.&lt;/p&gt;
&lt;h2 id="if-you-set-one-thing"&gt;If you set one thing
&lt;/h2&gt;&lt;p&gt;If you run release-plz on a multi-crate workspace and you change a single line
from the defaults, make it the tag template:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }}-v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And set it &lt;em&gt;before&lt;/em&gt; your first release, not during it, so it&amp;rsquo;s already in the
commit that introduces the version, because that&amp;rsquo;s the only commit a release will
ever read it from. Everything else here follows from two facts: the grain is
per-crate, and CI reads history while your laptop reads your working tree. Trust
the history.&lt;/p&gt;
&lt;p&gt;None of this is release-plz misbehaving. Every bit of it is documented and
deliberate. It just isn&amp;rsquo;t where you&amp;rsquo;ll think to look until it has published six
crates you can&amp;rsquo;t take back, which is roughly how I came to know it so well.&lt;/p&gt;</description></item><item><title>Same config, two answers</title><link>https://phpboyscout.uk/same-config-two-answers/</link><pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/same-config-two-answers/</guid><description>&lt;img src="https://phpboyscout.uk/same-config-two-answers/cover-same-config-two-answers.png" alt="Featured image of post Same config, two answers" /&gt;&lt;p&gt;Let me confess a small heresy first, because it&amp;rsquo;s the reason any of this
happened. After a career spent as a branching man, gitflow, gitlabflow, a
tidy &lt;code&gt;develop&lt;/code&gt; branch and a careful dance of merges, I&amp;rsquo;ve come round to
trunk-based development. I resisted it for years. It felt like working without
a net.&lt;/p&gt;
&lt;p&gt;What changed my mind was working solo with an AI pair. The branch ceremony that
earns its keep on a team of eight is just drag when it&amp;rsquo;s me and a model at
two in the morning. So I&amp;rsquo;ve softened on &amp;ldquo;main is always deployable&amp;rdquo; and let the
trunk act as the develop branch, with tagged releases as the actual source of
truth. For compiled languages, where the artefact you ship is a built, tagged
thing and not whatever&amp;rsquo;s on a server right now, that finally clicks.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;d already rolled this out on my Go and Terraform projects with
&lt;a class="link" href="https://github.com/apricote/releaser-pleaser" target="_blank" rel="noopener"
 &gt;releaser-pleaser&lt;/a&gt;, a GitLab-native
take on release-please: a bot keeps a Release MR open, and merging it cuts the
tag. It&amp;rsquo;s the same model I wrote about when
&lt;a class="link" href="https://phpboyscout.uk/reviewed-then-applied/" &gt;the infra repo moved to plan-on-merge, apply-on-tag&lt;/a&gt;.
Lovely. Then I came to do the same for rust-tool-base, and Rust, being Rust,
&lt;a class="link" href="https://phpboyscout.uk/rust-tool-base-the-same-idea/" &gt;had opinions&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="rust-brings-its-own-toolchain"&gt;Rust brings its own toolchain
&lt;/h2&gt;&lt;p&gt;releaser-pleaser is happy to tag a repo and write a release. What it does not do
is &lt;code&gt;cargo publish&lt;/code&gt; seventeen crates to crates.io in dependency order. Rust&amp;rsquo;s
release story isn&amp;rsquo;t &amp;ldquo;push a tag and let a runner build a binary&amp;rdquo;, it&amp;rsquo;s a whole
publishing pipeline with a public registry at the end of it, and that registry
has rules of its own. So for the Rust workspace I reached for the tool built for
exactly that job: &lt;a class="link" href="https://release-plz.dev" target="_blank" rel="noopener"
 &gt;release-plz&lt;/a&gt;. Same Release-MR shape,
but it understands cargo, versions every crate, and publishes the lot.&lt;/p&gt;
&lt;p&gt;That was the right call. Getting it to actually do it was where I spent two days
I&amp;rsquo;d quite like back.&lt;/p&gt;
&lt;h2 id="the-gauntlet-before-the-gun"&gt;The gauntlet before the gun
&lt;/h2&gt;&lt;p&gt;Before I got anywhere near the interesting failure, there was a run of CI
papercuts, the sort where every fix politely reveals the next one. GitLab checks
out a detached HEAD, and release-plz wants to be on a branch (&amp;ldquo;HEAD does not
point to a branch&amp;rdquo;), so you re-attach. Then the default &lt;code&gt;CI_JOB_TOKEN&lt;/code&gt; can&amp;rsquo;t push
to a protected repo, so you point the remote at a real token. Then release-plz
assumes you&amp;rsquo;re on GitHub and errors that the repo &amp;ldquo;is not hosted in GitHub&amp;rdquo;, so
you tell it &lt;code&gt;--forge gitlab&lt;/code&gt;. Then it refuses to run at all because the &lt;code&gt;pages&lt;/code&gt;
job left a &lt;code&gt;public/&lt;/code&gt; directory lying about and the working tree is &amp;ldquo;dirty&amp;rdquo;, so
you stop pulling artefacts into the job.&lt;/p&gt;
&lt;p&gt;Five merge requests before the thing would even &lt;em&gt;start&lt;/em&gt; doing its actual job.
You can read the
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/7afc42e/.gitlab-ci.yml#L379-L409" target="_blank" rel="noopener"
 &gt;scar tissue in the &lt;code&gt;before_script&lt;/code&gt;&lt;/a&gt;;
every line in it is a fix for something on that list. None of it was hard.
It was just death by a thousand cuts, and I was feeling quite smug by the time
it finally reached the publish step.&lt;/p&gt;
&lt;p&gt;I should not have been.&lt;/p&gt;
&lt;h2 id="tag-v051-already-exists"&gt;&amp;ldquo;Tag v0.5.1 already exists&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;My &lt;code&gt;release-plz.toml&lt;/code&gt; asked for
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/f6de975/release-plz.toml#L20-L21" target="_blank" rel="noopener"
 &gt;one tag per release&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_release_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That felt obviously right. It matched the repo&amp;rsquo;s existing &lt;code&gt;v0.5.0&lt;/code&gt; tag, it&amp;rsquo;s how
a single-crate project tags, and the crates all share one workspace version
anyway. One version, one tag. What&amp;rsquo;s to argue with?&lt;/p&gt;
&lt;p&gt;release-plz, that&amp;rsquo;s what. It tags &lt;em&gt;per crate&lt;/em&gt;. So it publishes a crate, creates
the tag, publishes the next crate, and tries to create the same tag again:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO published rtb-assets 0.5.1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR failed to release package
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Caused by:
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 0: failed to create git tag &amp;#39;v0.5.1&amp;#39; with ref &amp;#39;f6de975a75...&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 1: Response body: { &amp;#34;message&amp;#34;: &amp;#34;Tag v0.5.1 already exists&amp;#34; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 2: HTTP status client error (400 Bad Request) ... /repository/tags
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The collision is annoying. What makes it a proper trap is the half-second before
it: &lt;code&gt;published rtb-assets 0.5.1&lt;/code&gt;. That happened. On crates.io. For keeps. A
crates.io publish is forever, there is no unpublish, only a yank that still
leaves the name and version burned. So every time my flaky pipeline limped one
crate further and then fell over on the tag, it left another crate live on the
public registry that I could never take back. By the time the dust settled, six
of the seventeen were out there: &lt;code&gt;rtb-assets&lt;/code&gt; and &lt;code&gt;rtb-config&lt;/code&gt;, then on a later
retry &lt;code&gt;rtb-credentials&lt;/code&gt; and &lt;code&gt;rtb-error&lt;/code&gt;, then &lt;code&gt;rtb-app&lt;/code&gt; and &lt;code&gt;rtb-redact&lt;/code&gt;. Two
more permanent crates per failed run.&lt;/p&gt;
&lt;h2 id="i-assumed-the-default"&gt;I assumed the default
&lt;/h2&gt;&lt;p&gt;My first fix was the clever one, and it deserves to be on display because it&amp;rsquo;s
the whole lesson in miniature. I deleted the &lt;code&gt;git_tag_name&lt;/code&gt; line. My reasoning:
per-crate tags are release-plz&amp;rsquo;s native model, so surely its &lt;em&gt;default&lt;/em&gt; does the
right thing without me spelling it out. I was confident enough to write it into
the commit message: &amp;ldquo;per-crate tags/releases (release-plz defaults).&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The next run collided on &lt;code&gt;v0.5.1&lt;/code&gt;, exactly as before.&lt;/p&gt;
&lt;p&gt;Because release-plz&amp;rsquo;s default &lt;code&gt;git_tag_name&lt;/code&gt; is not per-crate. It&amp;rsquo;s the unified
&lt;code&gt;v{{ version }}&lt;/code&gt;. I had deleted a line that said the wrong thing and replaced it
with a default that said the &lt;em&gt;same&lt;/em&gt; wrong thing, then congratulated myself for
tidiness. If I&amp;rsquo;d spent thirty seconds on the configuration reference instead of
thirty seconds being clever, I&amp;rsquo;d have read that in black and white.&lt;/p&gt;
&lt;h2 id="same-config-two-answers"&gt;Same config, two answers
&lt;/h2&gt;&lt;p&gt;So I read the manual, and set it explicitly:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }}-v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_release_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }} v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On my laptop, a dry run rendered exactly the per-crate tags I wanted. In CI, the
very next run published another crate and then created the tag &lt;code&gt;v0.5.1&lt;/code&gt;. The
unified one. The wrong one. The one I had just, demonstrably, on main,
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/7afc42e/release-plz.toml#L21-L22" target="_blank" rel="noopener"
 &gt;replaced&lt;/a&gt;.
Same &lt;code&gt;release-plz.toml&lt;/code&gt;, two completely different answers depending on who was
asking.&lt;/p&gt;
&lt;p&gt;That one took me an embarrassingly long time to see. release-plz does not read
your config from the working tree when it runs a &lt;em&gt;release&lt;/em&gt;. It reads it from the
&lt;strong&gt;release commit&lt;/strong&gt;, the commit that introduced the version it&amp;rsquo;s releasing. My
version was still &lt;code&gt;0.5.1&lt;/code&gt;, set days earlier on a commit that still carried the
unified template. You can see it in the failure: the tag it tries to create is
pinned to &lt;code&gt;ref 'f6de975...'&lt;/code&gt;, an old commit, not the HEAD that held my fix.
Every edit I made at the tip of main was real, committed, and utterly invisible
to the release of 0.5.1, because no version bump had created a fresh release
commit for it to read. My fix was correct and inert at the same time. The
dry run read my working directory and looked perfect; CI read history and did
something else.&lt;/p&gt;
&lt;p&gt;There is no config change that rescues an in-flight release. The version was
already out, half-published, tagged wrong, and pointed at a commit I couldn&amp;rsquo;t
edit without bumping the version, which I couldn&amp;rsquo;t cleanly do with six crates
already live.&lt;/p&gt;
&lt;h2 id="doing-it-the-way-id-have-done-it-a-year-ago"&gt;Doing it the way I&amp;rsquo;d have done it a year ago
&lt;/h2&gt;&lt;p&gt;So I stopped. Three retries deep, each one a seventy-minute CI cycle thrown at an
opaque mismatch, six crates already immovable on crates.io, and a tooling problem
I now understood well enough to know the tool was never going to dig me out of
&lt;em&gt;this particular&lt;/em&gt; hole. The question quietly changed from &amp;ldquo;why is it doing this?&amp;rdquo;
to &amp;ldquo;am I going to keep grinding, or finish this the way I would have before I had
clever tooling?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;I went manual. &lt;code&gt;cargo publish&lt;/code&gt;, the remaining eleven, by hand, in dependency
order: the leaf crates first and the &lt;code&gt;rust-tool-base&lt;/code&gt; umbrella dead last,
because it depends on all of them. crates.io rate-limits new crate names, so
after a burst it simply made me wait, a roughly half-hour pause in the middle
while the registry caught its breath and I caught mine. Then one &lt;code&gt;v0.5.1&lt;/code&gt; tag,
cut by hand, and one GitLab release to match the convention. The next CI run came
up green, for the gloriously dull reason that there was nothing left to do:
every crate published, the tag already there.&lt;/p&gt;
&lt;h2 id="stop-being-clever-and-rtfm"&gt;Stop being clever and RTFM
&lt;/h2&gt;&lt;p&gt;The tool was never broken. Every single thing it did was documented behaviour I
hadn&amp;rsquo;t bothered to read: that the default tag template is unified, that the model
is per-crate, that a release reads its config from the release commit and not
from HEAD. I assumed my way past the manual three times in a row, and each
assumption cost me real, permanent state on a public registry that doesn&amp;rsquo;t take
returns.&lt;/p&gt;
&lt;p&gt;And that&amp;rsquo;s the part that actually stung, because I should have known better than
most. I wasn&amp;rsquo;t a beginner here. I knew the Release-MR pattern cold, I&amp;rsquo;d shipped it
half a dozen times with releaser-pleaser on my Go and Terraform repos. That
familiarity &lt;em&gt;was&lt;/em&gt; the trap. I trusted the pattern and skipped the tool, on the
lazy assumption that something I understood well in one tool would behave the same
in the next. release-plz carries the same design, but it&amp;rsquo;s a different tool, with
its own defaults and its own idea of where the config lives. The pattern came
across fine. The mechanics didn&amp;rsquo;t, and I never thought to check.&lt;/p&gt;
&lt;p&gt;So here&amp;rsquo;s the lesson, written down in the hope it sticks this time: no matter how
familiar I am with a pattern or a design, the moment I switch the tool that
implements it, reading the manual is paramount. The familiarity is exactly what
tempts you to skip it, and exactly why you can&amp;rsquo;t. (The narrower, more practical
one, while I&amp;rsquo;m here: a config change that affects how a release behaves has to
travel &lt;em&gt;with&lt;/em&gt; a version bump, or it sits there looking applied and doing nothing.)&lt;/p&gt;
&lt;p&gt;release-plz is genuinely good, and every release since has gone out clean on the
first try, the way &lt;a class="link" href="https://phpboyscout.uk/from-allow-failure-to-blocking/" &gt;the rest of the CI now does&lt;/a&gt;.
I just had to stop being clever long enough to read how it actually works. RTFM.
I&amp;rsquo;ll get it tattooed eventually.&lt;/p&gt;</description></item><item><title>From allow_failure to blocking</title><link>https://phpboyscout.uk/from-allow-failure-to-blocking/</link><pubDate>Sat, 30 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/from-allow-failure-to-blocking/</guid><description>&lt;img src="https://phpboyscout.uk/from-allow-failure-to-blocking/cover-from-allow-failure-to-blocking.png" alt="Featured image of post From allow_failure to blocking" /&gt;&lt;p&gt;There&amp;rsquo;s a special kind of CI job that everyone on a team quietly learns to
ignore: the one marked &lt;code&gt;allow_failure: true&lt;/code&gt;. It runs, it goes red, the
pipeline goes green anyway, and after the third time you stop looking at it. I
inherited six of those when I moved rust-tool-base&amp;rsquo;s CI to GitLab. Over a few
days I turned three of them into real gates, and the interesting part was never
the YAML. It was working out which ones had earned the right to block, and
which hadn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="what-allow_failure-actually-buys-you"&gt;What allow_failure actually buys you
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;allow_failure: true&lt;/code&gt; is genuinely useful, and quietly corrosive. It lets a job
report a problem without stopping the pipeline, which is exactly right for a
check that&amp;rsquo;s noisy, not yet stable, or guarding against something you can&amp;rsquo;t fix
this minute. The trouble is that a warning nobody is forced to act on is a
warning nobody acts on. Leave a job advisory long enough and it becomes
scenery: red, ignored, pointless. So an advisory check is really a promise,
&amp;ldquo;I&amp;rsquo;ll make this blocking once it&amp;rsquo;s trustworthy&amp;rdquo;, and a promise you only ever
mean to keep is just a lie you haven&amp;rsquo;t noticed yet.&lt;/p&gt;
&lt;p&gt;When I &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/2213f8e/.gitlab-ci.yml" target="_blank" rel="noopener"
 &gt;migrated rust-tool-base from GitHub Actions to GitLab CI&lt;/a&gt;,
the move landed six jobs as &lt;code&gt;allow_failure: true&lt;/code&gt;: the macOS and Windows tests,
the integration tests, &lt;code&gt;cargo-audit&lt;/code&gt;, &lt;code&gt;trivy&lt;/code&gt;, and coverage. That wasn&amp;rsquo;t
laziness. A migration is the wrong moment to also be fighting flaky gates. But
it left me holding six promises to either keep or admit I wasn&amp;rsquo;t going to.&lt;/p&gt;
&lt;h2 id="a-check-earns-the-right-to-block"&gt;A check earns the right to block
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the rule I settled on. A check earns the right to fail your build when
two things are true: it&amp;rsquo;s &lt;em&gt;meaningful&lt;/em&gt; (a red result is a real problem, not
noise) and it&amp;rsquo;s &lt;em&gt;reliable&lt;/em&gt; (it goes red only when there genuinely is a problem,
and it can actually run to completion). Flip a check to blocking before both
hold and you haven&amp;rsquo;t raised the bar, you&amp;rsquo;ve taught the team to force-merge past
red, which is worse than no gate at all, because now the red means nothing.&lt;/p&gt;
&lt;p&gt;Three of my six crossed that line within a few days. Three deliberately didn&amp;rsquo;t.
The reasons are the whole story.&lt;/p&gt;
&lt;h2 id="trivy-blocked-once-there-was-nothing-to-block-on"&gt;trivy: blocked once there was nothing to block on
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/f9cab20/.gitlab-ci.yml#L247-256" target="_blank" rel="noopener"
 &gt;&lt;code&gt;trivy&lt;/code&gt;&lt;/a&gt;
scans the dependency tree for HIGH and CRITICAL advisories. It went across as
advisory for an honest reason: the &lt;code&gt;Cargo.lock&lt;/code&gt; at migration time already
carried two known HIGH/CRITICAL advisories I hadn&amp;rsquo;t cleared yet, a
path-traversal in &lt;code&gt;gix-validate&lt;/code&gt; and a DNS-rebinding issue in &lt;code&gt;rmcp&lt;/code&gt;. Make
trivy blocking with those sitting there and the pipeline is red from day one,
over problems I already knew about and was already fixing. So it stayed
advisory until the dependency bumps cleared both, and then the &lt;code&gt;allow_failure&lt;/code&gt;
line came out. The gate never changed. The tree underneath it got clean enough
to stand on.&lt;/p&gt;
&lt;h2 id="integration-tests-blocked-once-it-could-actually-run"&gt;integration-tests: blocked once it could actually run
&lt;/h2&gt;&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/193f380/.gitlab-ci.yml#L200-226" target="_blank" rel="noopener"
 &gt;integration tests&lt;/a&gt;
stand up a real Gitea in a Docker-in-Docker service and talk to it. They were
advisory for a different reason: they couldn&amp;rsquo;t reliably run. dind needs a
privileged runner, and the suite was resolving the container host with a
hardcoded &lt;code&gt;127.0.0.1&lt;/code&gt; that didn&amp;rsquo;t hold everywhere. Blocking a job that fails
for infrastructure reasons rather than code reasons is the fastest way to make
people distrust the entire pipeline. So the fix wasn&amp;rsquo;t in the YAML, it was
making the thing dependable: &lt;code&gt;privileged&lt;/code&gt; set on the runner, and the host
resolved through the test library&amp;rsquo;s own &lt;code&gt;get_host()&lt;/code&gt; instead of a hardcoded
address. Once it ran the same way every time, it earned the gate.&lt;/p&gt;
&lt;h2 id="coverage-blocked-once-it-could-run-at-all-then-once-it-cleared-the-bar"&gt;coverage: blocked once it could run at all, then once it cleared the bar
&lt;/h2&gt;&lt;p&gt;Coverage is the two-step one, and my favourite, because it nearly didn&amp;rsquo;t make
it for a thoroughly undramatic reason: it ran out of memory. &lt;code&gt;cargo llvm-cov&lt;/code&gt;
instruments every test binary, and linking hundreds of instrumented object
files needs more RAM than the shared medium runner had, so the job bus-errored
on the link. I tagged it onto a larger runner, and then the shared SaaS runners
were switched off entirely, so the tag matched nothing and the job sat pending
forever.&lt;/p&gt;
&lt;p&gt;The fix was a &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/193f380/.gitlab-ci.yml#L200-226" target="_blank" rel="noopener"
 &gt;self-hosted homelab runner&lt;/a&gt;
with the RAM the instrumented link actually needs. I moved coverage there but
kept it advisory &lt;em&gt;for one run&lt;/em&gt;, to confirm the box could finish the build
before I trusted it. It did, at
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/1c9e589/.gitlab-ci.yml#L286-320" target="_blank" rel="noopener"
 &gt;73.22% line coverage&lt;/a&gt;,
so I set the gate to fail under 70% and made it blocking. Three points of
headroom: enough that ordinary churn won&amp;rsquo;t trip it, tight enough that a real
drop will. A coverage gate pinned to the current number is a tripwire that
fires on the very next commit; set it a touch below and it catches regressions
instead of normal life.&lt;/p&gt;
&lt;h2 id="the-three-i-left-advisory-on-purpose"&gt;The three I left advisory, on purpose
&lt;/h2&gt;&lt;p&gt;The point was never &amp;ldquo;block everything&amp;rdquo;. Three jobs are still &lt;code&gt;allow_failure&lt;/code&gt; in
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/d3c23fc/.gitlab-ci.yml" target="_blank" rel="noopener"
 &gt;the current pipeline&lt;/a&gt;,
deliberately. The macOS and Windows tests run on SaaS runners that bill by the
minute; they&amp;rsquo;re worth running, not worth blocking every merge of a Linux-first
project over a quota I&amp;rsquo;m choosing to ration. And &lt;code&gt;cargo-audit&lt;/code&gt; stays advisory
because &lt;code&gt;cargo-deny&lt;/code&gt; already does the blocking advisory check: cargo-audit is a
second opinion from a different database, and a second opinion that can veto
isn&amp;rsquo;t a second opinion, it&amp;rsquo;s a duplicate gate that will eventually disagree with
the first and block you on the difference.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the same rule from the other side. Those three haven&amp;rsquo;t earned the right
to block, because blocking them would cost more than it ever caught.&lt;/p&gt;
&lt;h2 id="the-upshot"&gt;The upshot
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;allow_failure: true&lt;/code&gt; is fine as a waiting room and corrosive as a destination.
Every advisory check is a promise to make it blocking once it&amp;rsquo;s both meaningful
and reliable, and the job is to keep the promise or admit you won&amp;rsquo;t. trivy
earned its gate when the advisories cleared, the integration tests when they
ran the same way every time, coverage when it had a runner with enough memory
and a threshold set just below the current mark. The three I left advisory
earned that standing too, by costing more to block than they&amp;rsquo;d catch. The YAML
is one deleted line per job. Knowing which line to delete, and when, is the
whole skill.&lt;/p&gt;</description></item><item><title>Pure-Rust Git, no git binary</title><link>https://phpboyscout.uk/pure-rust-git-no-git-binary/</link><pubDate>Wed, 13 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/pure-rust-git-no-git-binary/</guid><description>&lt;img src="https://phpboyscout.uk/pure-rust-git-no-git-binary/cover-pure-rust-git-no-git-binary.png" alt="Featured image of post Pure-Rust Git, no git binary" /&gt;&lt;p&gt;go-tool-base&amp;rsquo;s VCS support has two halves that get confused for one. One half talks to forge APIs (GitHub, GitLab) for releases and pull requests. The other talks to the &lt;code&gt;.git&lt;/code&gt; directory on disk: clone, history, diff, status. This post is mostly about the second half, and specifically about a question that turns out to have three answers in Rust, only one of which I&amp;rsquo;d recommend: how do you actually &lt;em&gt;do&lt;/em&gt; Git from inside a program?&lt;/p&gt;
&lt;h2 id="a-vcs-subsystem-with-two-halves"&gt;A VCS subsystem with two halves
&lt;/h2&gt;&lt;p&gt;go-tool-base has a VCS subsystem, and it does two distinct jobs.&lt;/p&gt;
&lt;p&gt;The first is forge APIs. GitHub and GitLab, Enterprise and nested group paths included. It authenticates, lists releases, fetches release assets, manages pull requests. The self-update machinery sits on this half, and it&amp;rsquo;s what a tool uses to ask &amp;ldquo;what&amp;rsquo;s the latest release?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The second is local Git. go-tool-base also carries a &lt;code&gt;RepoLike&lt;/code&gt; object, an abstraction over an actual Git repository on disk: clone it, read its commit history, diff two trees, check its status. This half doesn&amp;rsquo;t talk to a hosting service at all. It talks to the &lt;code&gt;.git&lt;/code&gt; directory.&lt;/p&gt;
&lt;p&gt;It would be easy to assume the second half grew out of the first. It didn&amp;rsquo;t, and where it actually came from is the part worth telling.&lt;/p&gt;
&lt;h2 id="a-capability-ahead-of-its-consumer"&gt;A capability ahead of its consumer
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;RepoLike&lt;/code&gt; object wasn&amp;rsquo;t built for go-tool-base. It came from another project, where it had already proved itself, and it was pulled into go-tool-base on purpose, with a specific future consumer in mind: the code generator.&lt;/p&gt;
&lt;p&gt;The plan is for the generator to use Git directly. When it scaffolds a new tool, that tool should start life as a Git repository, with a &lt;code&gt;git init&lt;/code&gt; and an initial commit. When you later regenerate, the generator should diff the regenerated template output against your working tree to detect drift, the same idea as &lt;a class="link" href="https://phpboyscout.uk/scaffolding-that-respects-your-edits/" &gt;respecting your edits&lt;/a&gt;. Both of those are local Git operations, not API calls, so the generator needs a repository abstraction to call into.&lt;/p&gt;
&lt;p&gt;That wiring isn&amp;rsquo;t finished yet. The generator doesn&amp;rsquo;t drive &lt;code&gt;RepoLike&lt;/code&gt; today. But the capability is in place, deliberately, ahead of the consumer that will use it, because the alternative is bolting Git support on later under deadline pressure, and that&amp;rsquo;s how you end up with the wrong abstraction.&lt;/p&gt;
&lt;p&gt;So when rust-tool-base was built, a repository abstraction was never in question. The Rust port carries the same capability for the same reason: a &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-vcs/src/git/mod.rs#L69" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Repo&lt;/code&gt; type&lt;/a&gt; with &lt;code&gt;init&lt;/code&gt;, &lt;code&gt;open&lt;/code&gt;, &lt;code&gt;clone&lt;/code&gt;, &lt;code&gt;walk&lt;/code&gt;, &lt;code&gt;diff&lt;/code&gt;, &lt;code&gt;blame&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;commit&lt;/code&gt;, &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;checkout&lt;/code&gt;, present and ready for the generator to wire into. The open question was never &lt;em&gt;whether&lt;/em&gt; to have it. It was how to &lt;em&gt;do&lt;/em&gt; Git from inside a Rust program, and there are three answers, only one of which is any good.&lt;/p&gt;
&lt;h2 id="three-ways-to-do-git-and-the-one-worth-picking"&gt;Three ways to do Git, and the one worth picking
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;Shell out to &lt;code&gt;git&lt;/code&gt;.&lt;/strong&gt; Run the &lt;code&gt;git&lt;/code&gt; binary as a subprocess and parse its output. It works until it doesn&amp;rsquo;t. The binary might not be installed. It might be a different version with different output. Its output is formatted for humans and changes between releases, so parsing it is a standing liability. You&amp;rsquo;ve made an undeclared dependency on a program you don&amp;rsquo;t ship.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Link libgit2.&lt;/strong&gt; libgit2 is the C library that reimplements Git as something you can call from code, and &lt;code&gt;git2&lt;/code&gt; is the Rust binding to it. It&amp;rsquo;s solid and widely used. But it&amp;rsquo;s a C dependency, which means a C toolchain in the build, and it&amp;rsquo;s consistently the single biggest source of cross-compilation pain in the Rust Git ecosystem. The musl builds, the Windows builds, the static linking: libgit2 is where they tend to break.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;gix&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;gix&lt;/code&gt; is a reimplementation of Git in pure Rust. No C library, no subprocess. It&amp;rsquo;s just Rust code, and it compiles and cross-compiles like any other crate, because that&amp;rsquo;s all it is. It&amp;rsquo;s also generally faster, and being pure Rust it fits the &lt;a class="link" href="https://phpboyscout.uk/a-framework-that-contains-no-unsafe/" &gt;no-&lt;code&gt;unsafe&lt;/code&gt;-in-first-party-code&lt;/a&gt; story far more comfortably than dragging a C library along.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rtb-vcs&lt;/code&gt; is &lt;code&gt;gix&lt;/code&gt;-first. The &lt;code&gt;Repo&lt;/code&gt; type is built on it. There&amp;rsquo;s no &lt;code&gt;git&lt;/code&gt; binary dependency, and there&amp;rsquo;s no libgit2 in a default build.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;gix&lt;/code&gt; is still maturing, and a few write paths, &lt;code&gt;push&lt;/code&gt; in particular, aren&amp;rsquo;t ready in it yet. For those, &lt;code&gt;git2&lt;/code&gt; (the libgit2 binding) is held in reserve as a documented fallback, to be wired behind an opt-in Cargo feature if and when a write path actually needs it. Until then a default build carries no libgit2 at all, and the common case, a tool that clones, reads history, diffs and commits, never pays its cross-compile cost. (The &lt;code&gt;gix&lt;/code&gt; backend itself sits behind an opt-in &lt;code&gt;git&lt;/code&gt; feature, which is &lt;a class="link" href="https://phpboyscout.uk/two-kinds-of-feature-flag/" &gt;exactly the feature-flag story&lt;/a&gt; from a couple of weeks back, doing real work.)&lt;/p&gt;
&lt;h2 id="repo-is-a-foundation-not-a-façade"&gt;&lt;code&gt;Repo&lt;/code&gt; is a foundation, not a façade
&lt;/h2&gt;&lt;p&gt;One design decision is worth calling out, because it came straight from a go-tool-base lesson.&lt;/p&gt;
&lt;p&gt;It would have been easy to build &lt;code&gt;Repo&lt;/code&gt; as a narrow façade exposing exactly what the scaffolder and the release-notes feature need today, and nothing else. That was rejected on purpose. go-tool-base&amp;rsquo;s &lt;code&gt;RepoLike&lt;/code&gt; is itself the cautionary tale: it arrived from another project, settled into a sensible abstraction, and is already lined up to carry a consumer, the generator, that wasn&amp;rsquo;t driving its design when it was first written. A repository abstraction gets used by code that doesn&amp;rsquo;t exist yet. Build one as a narrow façade around today&amp;rsquo;s needs and you&amp;rsquo;ve guaranteed a rewrite the first time a downstream tool wants something slightly different.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;rtb-vcs&lt;/code&gt;&amp;rsquo;s &lt;code&gt;Repo&lt;/code&gt; is built as a foundation: a sensible, reasonably complete vocabulary of Git operations that a tool author can compose richer behaviour on, without re-importing &lt;code&gt;gix&lt;/code&gt; directly and re-deriving the framework&amp;rsquo;s auth and concurrency conventions. The errors back this up. &lt;code&gt;gix&lt;/code&gt;&amp;rsquo;s error types aren&amp;rsquo;t leaked through the public API; they&amp;rsquo;re wrapped in semantic &lt;code&gt;RepoError&lt;/code&gt; variants, so the backend could be swapped, &lt;code&gt;gix&lt;/code&gt; to &lt;code&gt;git2&lt;/code&gt;, or to something else entirely, without breaking a single downstream caller.&lt;/p&gt;
&lt;h2 id="stepping-back"&gt;Stepping back
&lt;/h2&gt;&lt;p&gt;go-tool-base&amp;rsquo;s VCS support has two halves: forge-API calls for releases and pull requests, and a &lt;code&gt;RepoLike&lt;/code&gt; object for local Git operations. The repo half arrived from another project and is wired in ahead of its intended consumer, the code generator, which will use it to initialise repositories for scaffolded tools and to diff regenerated output for drift.&lt;/p&gt;
&lt;p&gt;rust-tool-base carries the same capability on purpose. Its &lt;code&gt;Repo&lt;/code&gt; type is built on &lt;code&gt;gix&lt;/code&gt;, a pure-Rust Git implementation, so there&amp;rsquo;s no dependency on an installed &lt;code&gt;git&lt;/code&gt; binary and no libgit2 C library in a default build, which keeps cross-compilation clean. &lt;code&gt;git2&lt;/code&gt; stays an opt-in fallback for the few write paths &lt;code&gt;gix&lt;/code&gt; can&amp;rsquo;t do yet. And &lt;code&gt;Repo&lt;/code&gt; is built as a foundation for downstream tools, with the backend wrapped behind its own error type so it can be replaced without breaking callers.&lt;/p&gt;</description></item><item><title>clap's global flag, except in a passthrough subtree</title><link>https://phpboyscout.uk/claps-global-flag-except-in-a-passthrough-subtree/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/claps-global-flag-except-in-a-passthrough-subtree/</guid><description>&lt;img src="https://phpboyscout.uk/claps-global-flag-except-in-a-passthrough-subtree/cover-claps-global-flag-except-in-a-passthrough-subtree.png" alt="Featured image of post clap's global flag, except in a passthrough subtree" /&gt;&lt;p&gt;&lt;code&gt;--output json&lt;/code&gt; worked everywhere. On the top-level command, on every ordinary subcommand, wherever the user fancied putting it. Then it stopped working in exactly one place, and of course it was the subcommand I&amp;rsquo;d been clever about.&lt;/p&gt;
&lt;h2 id="how-the-global-flag-is-meant-to-work"&gt;How the global flag is meant to work
&lt;/h2&gt;&lt;p&gt;clap has a lovely feature for this. Define &lt;code&gt;--output text|json&lt;/code&gt; once at the top, mark it &lt;code&gt;global = true&lt;/code&gt;, and it&amp;rsquo;s reachable from every subcommand: &lt;code&gt;mytool --output json widget&lt;/code&gt; and &lt;code&gt;mytool widget --output json&lt;/code&gt; land the same. You stop thinking about it.&lt;/p&gt;
&lt;h2 id="the-one-place-it-goes-missing"&gt;The one place it goes missing
&lt;/h2&gt;&lt;p&gt;One subcommand, &lt;code&gt;credentials&lt;/code&gt;, is a passthrough: it sets &lt;code&gt;subcommand_passthrough = true&lt;/code&gt;, which makes clap capture everything after the subcommand name as &lt;code&gt;trailing_var_arg&lt;/code&gt; and hand it on, the way &lt;code&gt;cargo run -- ...&lt;/code&gt; passes the trailing args to your program rather than to cargo. The handler then re-parses those captured tokens against its own clap definition.&lt;/p&gt;
&lt;p&gt;The trouble is that the captured tokens include &lt;code&gt;--output&lt;/code&gt;. clap&amp;rsquo;s &lt;code&gt;global = true&lt;/code&gt; propagation doesn&amp;rsquo;t reach a passthrough subtree, because the post-name tokens are taken as &lt;code&gt;trailing_var_arg&lt;/code&gt; before the outer parser ever sees them. So in this one subtree the global flag isn&amp;rsquo;t applied, and worse, when the inner parser re-parses the captured args it meets &lt;code&gt;--output&lt;/code&gt;, which it doesn&amp;rsquo;t define, and rejects it as unknown. The code says so where it matters, in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-cli/src/credentials.rs#L62-69" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-cli/src/credentials.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// clap&amp;#39;s outer `global = true` propagation works for normal
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// subcommands, but `subcommand_passthrough = true` captures
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// post-name tokens as `trailing_var_arg`, so the global
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// never reaches the outer parser for this subtree.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;strip_global_output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="parse-it-yourself-then-strip-it"&gt;Parse it yourself, then strip it
&lt;/h2&gt;&lt;p&gt;The fix is two moves. First, parse &lt;code&gt;--output&lt;/code&gt; out of the raw args by hand (there&amp;rsquo;s an &lt;code&gt;OutputMode::from_args_os&lt;/code&gt; for exactly that), so the output mode is still honoured. Then strip &lt;code&gt;--output&lt;/code&gt; out of the args before the inner parser runs, so the inner clap doesn&amp;rsquo;t choke on a flag it doesn&amp;rsquo;t define. &lt;code&gt;strip_global_output&lt;/code&gt; is the second move, from &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-cli/src/render.rs#L95" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-cli/src/render.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;--output=&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// inline form: drop just this token
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;--output&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// space-separated form: drop the token and its value
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It handles both &lt;code&gt;--output=json&lt;/code&gt; and &lt;code&gt;--output json&lt;/code&gt;, and it&amp;rsquo;s idempotent, so it&amp;rsquo;s safe to call whether or not the flag is actually present.&lt;/p&gt;
&lt;h2 id="the-takeaway"&gt;The takeaway
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;global = true&lt;/code&gt; and &lt;code&gt;trailing_var_arg&lt;/code&gt; are both &amp;ldquo;grab the args&amp;rdquo; features, and in a passthrough subcommand they reach for the same tokens. clap won&amp;rsquo;t arbitrate that overlap, and shouldn&amp;rsquo;t try to guess. So you arbitrate: parse the global out of the raw args yourself, strip it before you re-parse the rest, and the flag that &amp;ldquo;works everywhere&amp;rdquo; actually does.&lt;/p&gt;</description></item><item><title>Secrets that scrub themselves from RAM</title><link>https://phpboyscout.uk/secrets-that-scrub-themselves-from-ram/</link><pubDate>Wed, 06 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/secrets-that-scrub-themselves-from-ram/</guid><description>&lt;img src="https://phpboyscout.uk/secrets-that-scrub-themselves-from-ram/cover-secrets-that-scrub-themselves-from-ram.png" alt="Featured image of post Secrets that scrub themselves from RAM" /&gt;&lt;p&gt;A while ago I worked out &lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;where a CLI should keep your API key&lt;/a&gt;: env var, OS keychain, or, grudgingly, a literal in the config file. That answers where the secret &lt;em&gt;lives&lt;/em&gt;. It says nothing about what happens to it once it&amp;rsquo;s loaded and sitting in your process memory, which is the half where secrets actually tend to leak. Rust, it turns out, can do something about that half that Go simply can&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="what-go-tool-base-already-settled"&gt;What go-tool-base already settled
&lt;/h2&gt;&lt;p&gt;A while back I wrote about where a CLI should keep your API keys. The answer go-tool-base settled on was three storage modes, in a fixed precedence: an environment variable reference (the recommended default), the OS keychain (opt-in), or a literal value in the config file (legacy, and refused outright when &lt;code&gt;CI=true&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;rust-tool-base keeps that design unchanged. Same three modes, same precedence, same refusal of literal secrets in CI. A tool embeds a &lt;code&gt;CredentialRef&lt;/code&gt; in its typed config, and a &lt;code&gt;Resolver&lt;/code&gt; walks env, then keychain, then literal, then a well-known fallback variable, first hit wins. That part is a straight carry-over, because &lt;em&gt;where&lt;/em&gt; to keep the secret was design, and design survives the port.&lt;/p&gt;
&lt;p&gt;But storage is only half the life of a secret. The other half is what happens to it once it&amp;rsquo;s resolved and sitting in your process memory. That&amp;rsquo;s where Rust can do something Go can&amp;rsquo;t, and rust-tool-base takes the opening.&lt;/p&gt;
&lt;h2 id="the-two-ways-a-secret-leaks-after-youve-loaded-it"&gt;The two ways a secret leaks after you&amp;rsquo;ve loaded it
&lt;/h2&gt;&lt;p&gt;You&amp;rsquo;ve resolved the API key. It&amp;rsquo;s a value in memory now. Two very ordinary things can leak it from there, and neither involves your storage being wrong.&lt;/p&gt;
&lt;p&gt;The first is &lt;strong&gt;the log line&lt;/strong&gt;. Somewhere a developer writes a debug print of a config struct, or an error includes the struct that holds the key, or a panic dumps it. The secret is a string like any other string, so it renders like any other string, straight into a log aggregator that a lot of people can read.&lt;/p&gt;
&lt;p&gt;The second is &lt;strong&gt;the leftover bytes&lt;/strong&gt;. The key sat in a heap allocation. The variable goes out of scope, the allocation is freed, and on most runtimes &amp;ldquo;freed&amp;rdquo; just means &amp;ldquo;returned to the allocator&amp;rdquo;. The bytes are still there until something else writes over them. A core dump taken in that window contains your key. So does the next allocation that happens to land on that memory and gets logged before it&amp;rsquo;s overwritten.&lt;/p&gt;
&lt;p&gt;A Go string can&amp;rsquo;t really defend against either. Go strings are immutable, so you can&amp;rsquo;t zero one in place; the runtime copies them freely, so you can&amp;rsquo;t even track every copy; and there&amp;rsquo;s no compile-time barrier stopping anyone printing one. You can be disciplined, but discipline is all you&amp;rsquo;ve got.&lt;/p&gt;
&lt;h2 id="secretstring-closes-both"&gt;&lt;code&gt;SecretString&lt;/code&gt; closes both
&lt;/h2&gt;&lt;p&gt;rust-tool-base routes every secret through &lt;code&gt;secrecy::SecretString&lt;/code&gt;, and the crate is explicit that taking a plain &lt;code&gt;&amp;amp;str&lt;/code&gt; or &lt;code&gt;String&lt;/code&gt; for a secret is a &lt;em&gt;type error&lt;/em&gt;, not a style preference.&lt;/p&gt;
&lt;p&gt;For the log line, &lt;code&gt;SecretString&lt;/code&gt; has its own &lt;code&gt;Debug&lt;/code&gt; implementation, and it prints &lt;code&gt;[REDACTED]&lt;/code&gt;. Always. A config struct holding a &lt;code&gt;SecretString&lt;/code&gt; can be debug-printed, put in an error, caught in a panic, and the secret field shows up as &lt;code&gt;[REDACTED]&lt;/code&gt; every single time. You don&amp;rsquo;t have to remember not to log it. The type already won&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;For the leftover bytes, &lt;code&gt;SecretString&lt;/code&gt; zeroes its memory when it&amp;rsquo;s dropped. When the value goes out of scope, before the allocation is handed back, the bytes are overwritten. The window where a freed allocation still holds your key is closed. A core dump taken afterwards finds zeroes.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a third leak &lt;code&gt;SecretString&lt;/code&gt; blocks that&amp;rsquo;s easy to miss. It deliberately doesn&amp;rsquo;t implement &lt;code&gt;Serialize&lt;/code&gt;. You cannot serialise a &lt;code&gt;SecretString&lt;/code&gt;. That sounds like an inconvenience until you see what it prevents: a tool that loads config, changes one setting, and writes the whole struct back would, with an ordinary string, faithfully write the resolved secret to disk in plain text. Because &lt;code&gt;SecretString&lt;/code&gt; can&amp;rsquo;t be serialised, &lt;code&gt;CredentialRef&lt;/code&gt; can&amp;rsquo;t be either, and that accident is structurally impossible. Writing a secret back is a deliberate, separate path, never a side effect of saving config.&lt;/p&gt;
&lt;p&gt;When code genuinely needs the raw value, to drop it into an &lt;code&gt;Authorization&lt;/code&gt; header, it calls &lt;code&gt;expose_secret()&lt;/code&gt;. The name is the point. Getting at the plaintext is one explicit, greppable, reviewable call, and everywhere else the secret stays wrapped.&lt;/p&gt;
&lt;h2 id="discipline-versus-the-type-system"&gt;Discipline versus the type system
&lt;/h2&gt;&lt;p&gt;The plain framing is this. None of these leaks are exotic. Logging a struct, a core dump after a free, re-saving a config file: they&amp;rsquo;re all routine, and they&amp;rsquo;re all how real credentials end up somewhere they shouldn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s storage design is good, and rust-tool-base kept it. But in Go, &lt;em&gt;not leaking the secret once it&amp;rsquo;s in memory&lt;/em&gt; comes down to every developer being careful every time. In Rust, &lt;code&gt;SecretString&lt;/code&gt; makes &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;the type system&lt;/a&gt; carry it. The redaction, the zeroing, the un-serialisability aren&amp;rsquo;t things you remember to do. They&amp;rsquo;re things the secret does to itself because of what it is. That&amp;rsquo;s the part Go structurally can&amp;rsquo;t match, and it&amp;rsquo;s why the port didn&amp;rsquo;t just copy the storage modes across, it tightened the handling underneath them.&lt;/p&gt;
&lt;h2 id="the-gist"&gt;The gist
&lt;/h2&gt;&lt;p&gt;go-tool-base settled where a CLI keeps a secret: env var, keychain, or literal, in a fixed precedence. rust-tool-base keeps that design and hardens what happens once the secret is loaded.&lt;/p&gt;
&lt;p&gt;Every secret is a &lt;code&gt;secrecy::SecretString&lt;/code&gt;. It debug-prints as &lt;code&gt;[REDACTED]&lt;/code&gt;, so it can&amp;rsquo;t fall into a log by accident. Its memory is zeroed on drop, so it doesn&amp;rsquo;t survive in freed heap. It isn&amp;rsquo;t serialisable, so it can&amp;rsquo;t be written back to config by a blanket save. Getting the plaintext is one explicit &lt;code&gt;expose_secret()&lt;/code&gt; call. Go can only ask developers to be careful with a secret in memory; Rust lets the type be careful for them.&lt;/p&gt;</description></item><item><title>Two telemetry events, one mangled line</title><link>https://phpboyscout.uk/two-events-one-mangled-line/</link><pubDate>Sun, 03 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/two-events-one-mangled-line/</guid><description>&lt;img src="https://phpboyscout.uk/two-events-one-mangled-line/cover-two-events-one-mangled-line.png" alt="Featured image of post Two telemetry events, one mangled line" /&gt;&lt;p&gt;A line in a log file that no parser would touch. Not a wrong value, not a missing field. Half of one telemetry event spliced into the middle of another, like two people typing into the same text box at once. Which, it turns out, is pretty much exactly what had happened.&lt;/p&gt;
&lt;h2 id="a-format-with-exactly-one-rule"&gt;A format with exactly one rule
&lt;/h2&gt;&lt;p&gt;rust-tool-base writes its telemetry to a file as JSONL: one JSON object per line, newline at the end, next object on the next line. It&amp;rsquo;s a lovely format to work with precisely because it has one rule, and the rule is simple. Every line is a complete object. Honour that and you can &lt;code&gt;tail&lt;/code&gt; it, &lt;code&gt;grep&lt;/code&gt; it, stream it into anything. Break it once and the whole file is suspect, because now a reader can&amp;rsquo;t trust that a line is a line.&lt;/p&gt;
&lt;p&gt;So the one job the file sink has, beyond writing the right bytes, is to never let two events end up sharing a line.&lt;/p&gt;
&lt;h2 id="appending-is-atomic-though-isnt-it"&gt;&amp;ldquo;Appending is atomic, though, isn&amp;rsquo;t it?&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;The mental model I started with, and I suspect I&amp;rsquo;m not alone, was this: open the file with &lt;code&gt;O_APPEND&lt;/code&gt;, write the serialised event, and the operating system tacks it onto the end atomically. Two writers can&amp;rsquo;t tread on each other because each &lt;code&gt;write&lt;/code&gt; goes to wherever the end currently is, no questions asked. I&amp;rsquo;d half-remembered &lt;code&gt;O_APPEND&lt;/code&gt; as the thing that makes concurrent appending safe, full stop.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s half true, and the half that&amp;rsquo;s missing is the half that bit me.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;O_APPEND&lt;/code&gt; does guarantee one thing: the seek-to-end and the write happen as a unit, so you never get the classic lost-update where two writers compute the same offset and clobber each other. Good. What it does &lt;em&gt;not&lt;/em&gt; guarantee, on POSIX, is that a single &lt;code&gt;write()&lt;/code&gt; of arbitrary size is atomic with respect to other writers. That atomicity has a ceiling, and the ceiling is &lt;code&gt;PIPE_BUF&lt;/code&gt;: 4096 bytes on Linux. Under it, a write is all-or-nothing against other writes to the same file. Over it, the kernel is entirely within its rights to split your write into chunks, and another writer&amp;rsquo;s bytes can land in the gap between them.&lt;/p&gt;
&lt;h2 id="the-fat-event-that-went-over-the-edge"&gt;The fat event that went over the edge
&lt;/h2&gt;&lt;p&gt;For a long time nothing went wrong, which is the most dangerous way for a bug like this to behave. A typical event, a command name, a duration, a status, an attribute or two, serialises to a few hundred bytes. Comfortably under four kilobytes, so comfortably inside the atomic window. Hundreds of them a day, never a problem.&lt;/p&gt;
&lt;p&gt;Then an event came along with a lot of attributes on it, and its serialised form sailed past 4 KiB. Two of &lt;em&gt;those&lt;/em&gt; emitted at roughly the same moment, both over the line, and &lt;code&gt;O_APPEND&lt;/code&gt; did the only thing it had ever promised: it put each write at the end. It said nothing about not interleaving the bytes on the way, because past &lt;code&gt;PIPE_BUF&lt;/code&gt; that was never on offer. One spliced line, one file a parser would now choke on.&lt;/p&gt;
&lt;h2 id="the-fix-isnt-a-bigger-write-its-a-smaller-gate"&gt;The fix isn&amp;rsquo;t a bigger write, it&amp;rsquo;s a smaller gate
&lt;/h2&gt;&lt;p&gt;You can&amp;rsquo;t buy your way out of this with a bigger buffer, because there&amp;rsquo;s no buffer size that&amp;rsquo;s reliably atomic above &lt;code&gt;PIPE_BUF&lt;/code&gt;. The fix is to stop relying on the kernel for mutual exclusion you can do yourself: serialise the events through a lock, so only one &lt;code&gt;write&lt;/code&gt; is ever in flight at a time. The &lt;code&gt;FileSink&lt;/code&gt; carries a mutex for exactly that, and the doc comment on it is the whole post in a paragraph, from &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-telemetry/src/sink.rs#L99" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-telemetry/src/sink.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;FileSink&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;: &lt;span class="nc"&gt;PathBuf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// Serialises concurrent `emit` calls. Shared across `Clone`s of
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// the same `FileSink` so multiple handles to the same path also
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// serialise correctly.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;: &lt;span class="nc"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;tokio&lt;/span&gt;::&lt;span class="n"&gt;sync&lt;/span&gt;::&lt;span class="n"&gt;Mutex&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you don&amp;rsquo;t write Rust day to day (the &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;primer&lt;/a&gt; has the rest of the basics): &lt;code&gt;tokio::sync::Mutex&lt;/code&gt; is an async-aware lock, &lt;code&gt;.await&lt;/code&gt; is where a task waits its turn for that lock without blocking the whole thread, and the &lt;code&gt;Arc&lt;/code&gt; wrapper is shared ownership. That &lt;code&gt;Arc&lt;/code&gt; is the load-bearing bit: it means every clone of the &lt;code&gt;FileSink&lt;/code&gt; points at the &lt;em&gt;same&lt;/em&gt; gate, rather than each getting its own lock that guards nothing.&lt;/p&gt;
&lt;p&gt;The detail I like is &lt;em&gt;where&lt;/em&gt; the lock sits. The event is serialised to a string first, outside the critical section, because turning an event into JSON is the expensive part and there&amp;rsquo;s no reason to hold the gate while you do it. Only then does &lt;code&gt;emit&lt;/code&gt; take the lock, and it holds it across the whole open-write-flush, so no other emit can interleave a single byte:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Serialise the line outside the critical section.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;serde_json&lt;/span&gt;::&lt;span class="n"&gt;to_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;redacted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;&amp;#39;\n&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;_guard&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// ...create parent dir, open with append(true)...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;as_bytes&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;write_all&lt;/code&gt; makes sure the whole line goes out as one logical write from our side, and the gate makes sure ours is the only one happening. The 4 KiB cliff is still there in the kernel. We just never walk near it any more, because we&amp;rsquo;ve serialised the writers ourselves rather than hoping the OS would.&lt;/p&gt;
&lt;h2 id="the-bit-even-the-lock-cant-fix"&gt;The bit even the lock can&amp;rsquo;t fix
&lt;/h2&gt;&lt;p&gt;There is however a genuine limit, and the comment is upfront about it. The mutex lives in the process. Two &lt;code&gt;FileSink&lt;/code&gt;s in two &lt;em&gt;different&lt;/em&gt; processes, both pointed at the same file, are back to relying on &lt;code&gt;O_APPEND&lt;/code&gt; alone, and back under the 4 KiB ceiling. The lock can&amp;rsquo;t reach across a process boundary, so it doesn&amp;rsquo;t pretend to. The guidance there is the older, duller, correct one: give each process its own file and aggregate them somewhere else. Don&amp;rsquo;t have two processes fighting over one log file and expect the filesystem to referee.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;O_APPEND&lt;/code&gt; is a real guarantee, just a much smaller one than its name talks you into. It keeps your write at the end of the file, and it keeps concurrent writes from interleaving only while they stay under &lt;code&gt;PIPE_BUF&lt;/code&gt;, which on Linux is 4096 bytes. A fat JSON event slides straight over that and takes your file&amp;rsquo;s one rule with it.&lt;/p&gt;
&lt;p&gt;The fix was never exotic. Serialise the line, take a mutex, do the write under it, and the interleave can&amp;rsquo;t happen because there&amp;rsquo;s only ever one writer at a time. The POSIX manual had all of this written down long before I went and learned it the interesting way, which is, I&amp;rsquo;m told, how most people meet &lt;code&gt;PIPE_BUF&lt;/code&gt; too.&lt;/p&gt;</description></item><item><title>Supporting a provider, or actually using it</title><link>https://phpboyscout.uk/supporting-a-provider-or-actually-using-it/</link><pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/supporting-a-provider-or-actually-using-it/</guid><description>&lt;img src="https://phpboyscout.uk/supporting-a-provider-or-actually-using-it/cover-supporting-a-provider-or-actually-using-it.png" alt="Featured image of post Supporting a provider, or actually using it" /&gt;&lt;p&gt;If your CLI tool talks to an AI model, you don&amp;rsquo;t want to hard-wire one vendor. So you reach for a single client interface over several providers, which is the right call. The trap is the next step: build that interface on only what every provider has in common, and you quietly throw away the very features that made you want a particular provider in the first place. rust-tool-base&amp;rsquo;s &lt;code&gt;rtb-ai&lt;/code&gt; refuses to make that trade.&lt;/p&gt;
&lt;h2 id="the-pull-toward-one-interface"&gt;The pull toward one interface
&lt;/h2&gt;&lt;p&gt;If your CLI tool talks to an AI model, hard-wiring one vendor is a poor bet. One user has an Anthropic key, another an OpenAI key. Someone&amp;rsquo;s on Gemini. Someone runs Ollama locally because their data can&amp;rsquo;t leave the building. Someone points at an OpenAI-compatible endpoint from a provider you&amp;rsquo;ve never heard of. You don&amp;rsquo;t want a separate code path for each, so you want one &lt;code&gt;AiClient&lt;/code&gt; that all of them slot behind.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rtb-ai&lt;/code&gt; gets that unification from the &lt;code&gt;genai&lt;/code&gt; crate, which already speaks to Anthropic, OpenAI, Gemini, Ollama and OpenAI-compatible endpoints. One interface, five providers, the tool author picks one in config. The Go sibling makes the same bet: go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package also unifies several providers, behind &lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;an interface deliberately kept to four methods&lt;/a&gt;. So far this is the obvious design, and if it were the whole design there&amp;rsquo;d be nothing to write about.&lt;/p&gt;
&lt;h2 id="what-unified-quietly-costs-you"&gt;What &amp;ldquo;unified&amp;rdquo; quietly costs you
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the catch in any unified interface. It can only expose what every provider behind it has in common.&lt;/p&gt;
&lt;p&gt;The common subset is plain chat. Messages go in, text comes out, optionally streamed token by token. That&amp;rsquo;s real and it&amp;rsquo;s useful and every provider does it. But the common subset is also the &lt;em&gt;floor&lt;/em&gt;, and the features that make a particular provider worth choosing are almost never on the floor. They&amp;rsquo;re the things only that provider does.&lt;/p&gt;
&lt;p&gt;Anthropic is the sharp example, because it has three features that matter and not one of them is common-subset.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prompt caching.&lt;/strong&gt; You can mark the stable parts of a request, the system prompt and the tool list, as cacheable. The provider keeps them warm, and on the next turn you aren&amp;rsquo;t billed to re-send and re-process text that didn&amp;rsquo;t change. On a long agent loop, where the same large system prompt rides along on every single turn, that&amp;rsquo;s a substantial saving in both cost and latency.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Extended thinking.&lt;/strong&gt; The model works through a hard problem in a visible, budgeted reasoning pass before it commits to an answer, and you can see that reasoning.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Citations.&lt;/strong&gt; Structured references back to source material in the response.&lt;/p&gt;
&lt;p&gt;A client built strictly on the common subset can&amp;rsquo;t express any of those. It has no field for them, because four of the five providers wouldn&amp;rsquo;t know what to do with the field. So a purely lowest-common-denominator client would &amp;ldquo;support&amp;rdquo; Anthropic and then use it badly, leaving its best features unreachable. Support as a checkbox, not as the point.&lt;/p&gt;
&lt;h2 id="the-escape-hatch"&gt;The escape hatch
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;rtb-ai&lt;/code&gt;&amp;rsquo;s answer is to not choose. It runs two implementations under one interface.&lt;/p&gt;
&lt;p&gt;For OpenAI, Gemini, Ollama and OpenAI-compatible endpoints, calls route through &lt;code&gt;genai&lt;/code&gt;, the unified path. For Anthropic, every method drops to a &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-ai/src/anthropic.rs#L1" target="_blank" rel="noopener"
 &gt;direct &lt;code&gt;reqwest&lt;/code&gt; implementation&lt;/a&gt; straight against the Messages API. Same &lt;code&gt;AiClient&lt;/code&gt; on the surface, a different implementation underneath, selected by which provider the config names.&lt;/p&gt;
&lt;p&gt;And the request type has deliberate room for the difference:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;ChatRequest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;: &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;max_tokens&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;u32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Anthropic-only: enables prompt caching at every stable point.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Ignored on non-Anthropic providers.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cache_control&lt;/span&gt;: &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Anthropic-only: extended-thinking budget. `None` disables.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Ignored on non-Anthropic providers.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;thinking&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ThinkingMode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Set &lt;code&gt;cache_control&lt;/code&gt; and the Anthropic-direct path inserts cache breakpoints at the three stable points: the system prompt, the tool list, and the first message. Set &lt;code&gt;thinking&lt;/code&gt; and it adds the thinking block, and streaming surfaces a separate &lt;code&gt;ThinkingToken&lt;/code&gt; event so you can show the reasoning apart from the answer. On a non-Anthropic provider, both fields are simply ignored. The interface carries them; only the implementation that understands them acts on them.&lt;/p&gt;
&lt;h2 id="a-hatch-not-a-leak"&gt;A hatch, not a leak
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s worth being precise about why this isn&amp;rsquo;t the thing it superficially resembles, which is a leaky abstraction.&lt;/p&gt;
&lt;p&gt;A leaky abstraction is one where implementation details bleed through that you didn&amp;rsquo;t intend and can&amp;rsquo;t reason about. The abstraction quietly fails to abstract, and you&amp;rsquo;re left guessing which provider you&amp;rsquo;re really talking to.&lt;/p&gt;
&lt;p&gt;This is the opposite of that. The two Anthropic-only fields aren&amp;rsquo;t a leak. They&amp;rsquo;re named, documented as Anthropic-only, inert everywhere else, and right there in the public type for anyone to see. The interface is uniform for the common case and &lt;em&gt;deliberately, visibly&lt;/em&gt; non-uniform at exactly the points where uniformity would have cost you the good features. You opt into provider-specifics by setting a field. You stay fully portable by leaving it at its default. Nothing bleeds; you decide.&lt;/p&gt;
&lt;p&gt;The same design line explains what &lt;em&gt;does&lt;/em&gt; stay in the unified path. Structured output, &lt;code&gt;chat_structured::&amp;lt;T&amp;gt;&lt;/code&gt;, sends a JSON Schema derived from your Rust type with the request and validates the reply against it before handing you a typed &lt;code&gt;T&lt;/code&gt;. That&amp;rsquo;s a portability win that costs nothing across providers, so it belongs in the common interface. The split isn&amp;rsquo;t &amp;ldquo;Anthropic versus the rest&amp;rdquo;. It&amp;rsquo;s &amp;ldquo;features that are free to unify go in the unified path; features that aren&amp;rsquo;t get a designed door&amp;rdquo;. Prompt caching and extended thinking get the door, because flattening them away would be the expensive kind of convenient.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;A CLI tool that integrates AI wants one client over several providers, and a unified interface can only expose what those providers share. The shared floor is plain chat, and the features worth choosing a provider &lt;em&gt;for&lt;/em&gt;, like Anthropic&amp;rsquo;s prompt caching, extended thinking and citations, are never on the floor.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rtb-ai&lt;/code&gt; keeps both. &lt;code&gt;genai&lt;/code&gt; provides the unified path across five providers; an Anthropic-direct &lt;code&gt;reqwest&lt;/code&gt; path drops below the abstraction for the features &lt;code&gt;genai&lt;/code&gt; can&amp;rsquo;t reach, and &lt;code&gt;ChatRequest&lt;/code&gt; carries the Anthropic-only fields openly, ignored elsewhere. Uniform where uniformity is free, with a designed escape hatch where it isn&amp;rsquo;t. That&amp;rsquo;s the difference between supporting a provider and actually using it.&lt;/p&gt;</description></item><item><title>Errors without an error handler</title><link>https://phpboyscout.uk/errors-without-an-error-handler/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/errors-without-an-error-handler/</guid><description>&lt;img src="https://phpboyscout.uk/errors-without-an-error-handler/cover-errors-without-an-error-handler.png" alt="Featured image of post Errors without an error handler" /&gt;&lt;p&gt;In &lt;a class="link" href="https://phpboyscout.uk/what-survives-a-port/" &gt;the porting post&lt;/a&gt; I said go-tool-base&amp;rsquo;s error handler was one of the bits that &lt;em&gt;didn&amp;rsquo;t&lt;/em&gt; survive the move to Rust, and promised to come back to it. Here&amp;rsquo;s the come-back. The short version is that Rust hands you, for free, the single consistent error exit that go-tool-base had to build a whole component to get.&lt;/p&gt;
&lt;h2 id="what-go-tool-base-built"&gt;What go-tool-base built
&lt;/h2&gt;&lt;p&gt;A while ago I &lt;a class="link" href="https://phpboyscout.uk/errors-that-tell-the-user-what-to-do-next/" &gt;wrote about error handling in go-tool-base&lt;/a&gt;. The core of it: an error should carry a &lt;em&gt;hint&lt;/em&gt;, a separate field of human guidance telling the user what to do next, kept apart from the error&amp;rsquo;s identity so code can still match on it.&lt;/p&gt;
&lt;p&gt;The other half of that post was about consistency. Every go-tool-base command returns its errors the idiomatic Cobra way, and they all funnel into one &lt;code&gt;Execute()&lt;/code&gt; wrapper at the root, which routes every error through one &lt;code&gt;ErrorHandler&lt;/code&gt;. One door out. Presentation decided in exactly one place, so no command can render a failure differently from its neighbour.&lt;/p&gt;
&lt;p&gt;That handler is a real object. It exists, it&amp;rsquo;s wired in, it&amp;rsquo;s the thing every error passes through. Building it was a deliberate piece of work, and it was the right call for Go.&lt;/p&gt;
&lt;p&gt;When I rebuilt this in Rust, the handler didn&amp;rsquo;t survive the move. Not because consistency stopped mattering. Because Rust gives you the single exit for free, and an object to enforce it would just be re-implementing something the language already does for you.&lt;/p&gt;
&lt;h2 id="the-shape-of-a-rust-error"&gt;The shape of a Rust error
&lt;/h2&gt;&lt;p&gt;Start with the type. In rust-tool-base every crate defines its own error enum, and every one of them derives two traits:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[derive(Debug, thiserror::Error, miette::Diagnostic)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;ConfigError&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;#[error(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;config file not found at {path}&amp;#34;&lt;/span&gt;&lt;span class="cp"&gt;)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;#[diagnostic(
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt; code(rtb::config::not_found),
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt; help(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;run `mytool init` to create one, or set MYTOOL_CONFIG&amp;#34;&lt;/span&gt;&lt;span class="cp"&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt; )]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NotFound&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;: &lt;span class="nc"&gt;PathBuf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;thiserror::Error&lt;/code&gt; makes it a proper error type. &lt;code&gt;miette::Diagnostic&lt;/code&gt; is the interesting one. A &lt;code&gt;Diagnostic&lt;/code&gt; is an error that also carries the things you&amp;rsquo;d want when &lt;em&gt;presenting&lt;/em&gt; it: a stable &lt;code&gt;code&lt;/code&gt;, a severity, a &lt;code&gt;help&lt;/code&gt; string, and optionally source labels pointing at spans of input. The &lt;code&gt;help&lt;/code&gt; line is the same idea as go-tool-base&amp;rsquo;s hint, the recovery step, except here it&amp;rsquo;s an attribute on the variant rather than a field threaded through a wrapper.&lt;/p&gt;
&lt;p&gt;So the guidance lives on the error, structured, from the moment the error is created.&lt;/p&gt;
&lt;h2 id="there-is-no-handler-theres-a-convention"&gt;There is no handler, there&amp;rsquo;s a convention
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s where Rust does the work go-tool-base&amp;rsquo;s handler was built to do.&lt;/p&gt;
&lt;p&gt;A rust-tool-base &lt;code&gt;main&lt;/code&gt; looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[tokio::main]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;async&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nc"&gt;miette&lt;/span&gt;::&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rtb&lt;/span&gt;::&lt;span class="n"&gt;cli&lt;/span&gt;::&lt;span class="n"&gt;Application&lt;/span&gt;::&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;VersionInfo&lt;/span&gt;::&lt;span class="n"&gt;from_env&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;main&lt;/code&gt; returns &lt;code&gt;miette::Result&amp;lt;()&amp;gt;&lt;/code&gt;. Every command&amp;rsquo;s &lt;code&gt;run&lt;/code&gt; returns a &lt;code&gt;Result&lt;/code&gt; too. In between, errors propagate with the &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;&lt;code&gt;?&lt;/code&gt; operator&lt;/a&gt;: a function that hits an error returns it upward, immediately, and the caller does the same, all the way to &lt;code&gt;main&lt;/code&gt;. Nobody writes a &amp;ldquo;check this error&amp;rdquo; call. &lt;code&gt;?&lt;/code&gt; is the propagation.&lt;/p&gt;
&lt;p&gt;And when an error reaches &lt;code&gt;main&lt;/code&gt; and &lt;code&gt;main&lt;/code&gt; returns it, &lt;em&gt;something&lt;/em&gt; has to render it for the user. That something is a &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-error/src/hook.rs#L41" target="_blank" rel="noopener"
 &gt;report hook&lt;/a&gt;. rust-tool-base installs one at startup, and from then on any &lt;code&gt;Diagnostic&lt;/code&gt; that exits &lt;code&gt;main&lt;/code&gt; is rendered through it: the code, the severity, the help text, the source labels, with colour. One renderer, installed once.&lt;/p&gt;
&lt;p&gt;Look at what that adds up to. Every error in the program flows to one place, &lt;code&gt;main&lt;/code&gt;. It&amp;rsquo;s rendered by one thing, the hook. Presentation is decided in exactly one location and no command can deviate from it. That&amp;rsquo;s precisely the property go-tool-base&amp;rsquo;s &lt;code&gt;ErrorHandler&lt;/code&gt; was built to guarantee. The difference is that nobody built it. The single exit is just where &lt;code&gt;?&lt;/code&gt; propagation ends, and the single renderer is one hook. The language&amp;rsquo;s own convention for returning errors from &lt;code&gt;main&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the funnel.&lt;/p&gt;
&lt;h2 id="errors-are-values-all-the-way"&gt;Errors are values, all the way
&lt;/h2&gt;&lt;p&gt;The thing that took me a moment to fully trust is that there&amp;rsquo;s no funnel to maintain, because there&amp;rsquo;s no funnel as an object. go-tool-base&amp;rsquo;s handler is a component: it can drift, it has to be kept in the path, a command could in principle be wired to bypass it. The Rust version cannot be bypassed, because bypassing it would mean a command not returning its error, and an error you don&amp;rsquo;t return is a compile-time warning at best and dead-obvious wrong code at worst.&lt;/p&gt;
&lt;p&gt;So the model is just: errors are values, you return them, &lt;code&gt;?&lt;/code&gt; carries them up, &lt;code&gt;main&lt;/code&gt; hands the last one to the hook. The consistency isn&amp;rsquo;t enforced by a guard. It&amp;rsquo;s the only thing the shape of the language really lets you do.&lt;/p&gt;
&lt;p&gt;go-tool-base reaches a single, consistent error exit by building one and routing everything through it. rust-tool-base reaches the same exit by having errors be ordinary return values and letting them fall out of &lt;code&gt;main&lt;/code&gt;. Same outcome. One of them is a component you own; the other is a convention you inherit.&lt;/p&gt;
&lt;h2 id="worth-remembering"&gt;Worth remembering
&lt;/h2&gt;&lt;p&gt;go-tool-base funnels every error through one &lt;code&gt;ErrorHandler&lt;/code&gt; so presentation stays consistent. That handler is a deliberately built component, and it&amp;rsquo;s the right design in Go.&lt;/p&gt;
&lt;p&gt;rust-tool-base has no handler. Every crate&amp;rsquo;s error type derives &lt;code&gt;miette::Diagnostic&lt;/code&gt;, carrying its code, severity and help text. Errors propagate with &lt;code&gt;?&lt;/code&gt; to &lt;code&gt;main&lt;/code&gt;, which returns &lt;code&gt;miette::Result&lt;/code&gt;, and a framework-installed hook renders whatever comes out. The single consistent exit is the end of &lt;code&gt;?&lt;/code&gt; propagation, and the single renderer is one hook. The funnel go-tool-base built by hand is, in Rust, just the language&amp;rsquo;s return-from-&lt;code&gt;main&lt;/code&gt; convention.&lt;/p&gt;</description></item><item><title>Two kinds of feature flag</title><link>https://phpboyscout.uk/two-kinds-of-feature-flag/</link><pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/two-kinds-of-feature-flag/</guid><description>&lt;img src="https://phpboyscout.uk/two-kinds-of-feature-flag/cover-two-kinds-of-feature-flag.png" alt="Featured image of post Two kinds of feature flag" /&gt;&lt;p&gt;go-tool-base has feature flags: switches that decide which built-in commands are live in a given run. rust-tool-base has those too. But it also has a second, completely separate kind of flag, and the difference between them is one of those distinctions that&amp;rsquo;s obvious the moment you see it and dangerously easy to conflate before you do. One decides what a command &lt;em&gt;does&lt;/em&gt;. The other decides whether a chunk of code is &lt;em&gt;in the binary at all&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="a-workspace-of-crates"&gt;A workspace of crates
&lt;/h2&gt;&lt;p&gt;Before the flags, the shape that makes them possible. go-tool-base is one Go module with packages under &lt;code&gt;pkg/&lt;/code&gt;. rust-tool-base is a &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;Cargo &lt;em&gt;workspace&lt;/em&gt;&lt;/a&gt; of seventeen crates: &lt;code&gt;rtb-app&lt;/code&gt;, &lt;code&gt;rtb-config&lt;/code&gt;, &lt;code&gt;rtb-cli&lt;/code&gt;, &lt;code&gt;rtb-vcs&lt;/code&gt;, &lt;code&gt;rtb-ai&lt;/code&gt;, &lt;code&gt;rtb-mcp&lt;/code&gt;, &lt;code&gt;rtb-docs&lt;/code&gt;, &lt;code&gt;rtb-telemetry&lt;/code&gt;, and so on, with an umbrella crate called &lt;code&gt;rtb&lt;/code&gt; that re-exports the public surface.&lt;/p&gt;
&lt;p&gt;That isn&amp;rsquo;t tidiness for its own sake. Each subsystem being a separately compilable crate is what gives you a unit you can include or exclude &lt;em&gt;wholesale&lt;/em&gt;. Hold onto that, because it&amp;rsquo;s the hinge for everything below.&lt;/p&gt;
&lt;h2 id="the-flag-go-tool-base-already-has"&gt;The flag go-tool-base already has
&lt;/h2&gt;&lt;p&gt;go-tool-base has &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/props/tool.go#L43" target="_blank" rel="noopener"
 &gt;feature flags&lt;/a&gt;, and I&amp;rsquo;d describe them as runtime flags. A tool built on it can enable or disable built-in commands:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetFeatures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Disable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InitCmd&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Enable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AiCmd&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;At startup the framework resolves that set and decides which commands are reachable for this run. The &lt;code&gt;init&lt;/code&gt; command might be present in the binary but switched off; the &lt;code&gt;ai&lt;/code&gt; command might be switched on. It&amp;rsquo;s about the &lt;em&gt;user-facing surface&lt;/em&gt;: which commands exist for someone typing &lt;code&gt;--help&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;rust-tool-base keeps this idea. A command carries a &lt;code&gt;CommandSpec&lt;/code&gt; with an optional &lt;code&gt;feature&lt;/code&gt; field, and the runtime decides whether a feature-gated command is reachable. Same purpose: shape the surface per invocation.&lt;/p&gt;
&lt;p&gt;If that were the whole story, there&amp;rsquo;d be nothing to write. The reason there&amp;rsquo;s a post is the &lt;em&gt;other&lt;/em&gt; kind of flag, which Rust makes available and Go really doesn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="the-flag-rust-adds"&gt;The flag Rust adds
&lt;/h2&gt;&lt;p&gt;Cargo features are a compile-time mechanism. The &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb/Cargo.toml" target="_blank" rel="noopener"
 &gt;&lt;code&gt;rtb&lt;/code&gt; umbrella crate declares them&lt;/a&gt; like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;features&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;cli&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;docs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;mcp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;credentials&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tui&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;cli&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-cli&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;ai&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;rtb-docs?/ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;vcs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-vcs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;telemetry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-telemetry&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;full&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;cli&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;docs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;mcp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;credentials&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tui&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;telemetry&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;vcs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each subsystem is an &lt;em&gt;optional&lt;/em&gt; crate dependency, and a feature switches it on. This is a different kind of switch entirely, and the difference is the whole point.&lt;/p&gt;
&lt;p&gt;A runtime flag decides what a command does &lt;em&gt;while the program runs&lt;/em&gt;. The code is in the binary either way; the flag just gates it.&lt;/p&gt;
&lt;p&gt;A Cargo feature decides what&amp;rsquo;s &lt;em&gt;in the binary in the first place&lt;/em&gt;. Build a tool without the &lt;code&gt;vcs&lt;/code&gt; feature and &lt;code&gt;rtb-vcs&lt;/code&gt; is not compiled. Its dependencies are not compiled. &lt;code&gt;gix&lt;/code&gt;, the pure-Rust Git implementation &lt;code&gt;rtb-vcs&lt;/code&gt; pulls in, roughly two and a half megabytes of it, is not compiled and not linked. It isn&amp;rsquo;t switched off in the binary. It was never in the binary. The compiler never even saw it.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s something a runtime flag cannot do, because by the time anything runs, the binary already exists with everything in it.&lt;/p&gt;
&lt;h2 id="two-axes-kept-separate"&gt;Two axes, kept separate
&lt;/h2&gt;&lt;p&gt;So rust-tool-base has two flag systems answering two genuinely different questions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cargo features&lt;/strong&gt; answer: &lt;em&gt;what is this binary made of?&lt;/em&gt; They&amp;rsquo;re decided when you build the tool, in &lt;code&gt;Cargo.toml&lt;/code&gt;. They control compilation, binary size, dependency surface, and compile time. A tool that never touches Git builds without &lt;code&gt;vcs&lt;/code&gt; and is smaller, faster to compile, and has a smaller dependency tree to audit. A tool that wants everything turns on &lt;code&gt;full&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Runtime feature flags&lt;/strong&gt; answer: &lt;em&gt;what can the user do in this run?&lt;/em&gt; They&amp;rsquo;re decided as the program starts. They control which commands appear, which paths are reachable.&lt;/p&gt;
&lt;p&gt;These could have been mashed into one mechanism, and it would have been a mistake. The app-context design notes are blunt about it: feature gating doesn&amp;rsquo;t belong on the per-command context object, because a feature-gated command &amp;ldquo;either exists or doesn&amp;rsquo;t&amp;rdquo; rather than changing its behaviour mid-run. Compile-time composition is one decision, made by the person building the tool. Runtime gating is another, made per invocation. Conflating them would mean you couldn&amp;rsquo;t reason cleanly about either.&lt;/p&gt;
&lt;h2 id="the-go-version-of-this-had-to-be-hand-built"&gt;The Go version of this had to be hand-built
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t a thing Go simply lacks. I &lt;a class="link" href="https://phpboyscout.uk/the-blank-import-that-keeps-a-dependency-out-of-your-binary/" &gt;wrote a whole post&lt;/a&gt; about how go-tool-base keeps its optional keychain dependency out of binaries that don&amp;rsquo;t want it, using a blank import and the linker&amp;rsquo;s dead-code elimination. It works. But it was a piece of deliberate engineering for &lt;em&gt;one&lt;/em&gt; dependency, and getting it right took care.&lt;/p&gt;
&lt;p&gt;Cargo features make that same outcome a first-class, declarative thing, and not for one dependency but for every subsystem the framework has. You don&amp;rsquo;t engineer the exclusion. You name a feature and leave it off. The crate, and its whole subtree, stays out. Rust&amp;rsquo;s build system was designed for exactly this, and rust-tool-base leans on it across the entire workspace rather than hand-rolling it once.&lt;/p&gt;
&lt;h2 id="where-this-leaves-us"&gt;Where this leaves us
&lt;/h2&gt;&lt;p&gt;go-tool-base has runtime feature flags: they decide, per invocation, which built-in commands are reachable. rust-tool-base keeps that, and adds a second kind that Rust makes available.&lt;/p&gt;
&lt;p&gt;Cargo features decide what the binary is &lt;em&gt;compiled from&lt;/em&gt;. Each of the framework&amp;rsquo;s seventeen crates is an optional dependency, and a feature switched off means that crate and its entire dependency subtree are never compiled or linked. A runtime flag gates what code &lt;em&gt;does&lt;/em&gt;; a Cargo feature gates whether code &lt;em&gt;is there at all&lt;/em&gt;. Two axes, two questions, deliberately kept as separate systems.&lt;/p&gt;</description></item><item><title>forbid means forbid, until linkme needs a word</title><link>https://phpboyscout.uk/forbid-means-forbid-until-linkme-needs-a-word/</link><pubDate>Wed, 29 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/forbid-means-forbid-until-linkme-needs-a-word/</guid><description>&lt;img src="https://phpboyscout.uk/forbid-means-forbid-until-linkme-needs-a-word/cover-forbid-means-forbid-until-linkme-needs-a-word.png" alt="Featured image of post forbid means forbid, until linkme needs a word" /&gt;&lt;p&gt;There&amp;rsquo;s a line at the top of every production crate in rust-tool-base that I&amp;rsquo;m quietly proud of: &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;. And there are a couple of files that have to say &lt;code&gt;#![allow(unsafe_code)]&lt;/code&gt; instead. Not because I wrote anything unsafe. Because a macro did, on my behalf, and &lt;code&gt;forbid&lt;/code&gt; doesn&amp;rsquo;t care whose unsafe it is.&lt;/p&gt;
&lt;h2 id="why-forbid-and-why-it-isnt-the-whole-story"&gt;Why forbid, and why it isn&amp;rsquo;t the whole story
&lt;/h2&gt;&lt;p&gt;rust-tool-base makes a bold promise: &lt;a class="link" href="https://phpboyscout.uk/a-framework-that-contains-no-unsafe/" &gt;no unsafe in its own code&lt;/a&gt;. The strong form of that is &lt;code&gt;forbid&lt;/code&gt;, not &lt;code&gt;deny&lt;/code&gt;. &lt;code&gt;deny(unsafe_code)&lt;/code&gt; makes unsafe a compile error that any module can quietly re-permit with its own &lt;code&gt;#[allow]&lt;/code&gt;. &lt;code&gt;forbid&lt;/code&gt; can&amp;rsquo;t be overridden from inside the crate at all. That&amp;rsquo;s the appeal: nobody gets to wave unsafe through in a hurry.&lt;/p&gt;
&lt;p&gt;So the workspace lint sits at &lt;code&gt;deny&lt;/code&gt;, and every production &lt;code&gt;lib.rs&lt;/code&gt; then tightens it to &lt;code&gt;forbid&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Cargo.toml&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rust&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;unsafe_code&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;deny&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// crates/rtb-error/src/lib.rs
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#![forbid(unsafe_code)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Why &lt;code&gt;deny&lt;/code&gt; at the workspace but &lt;code&gt;forbid&lt;/code&gt; in each crate? Because &lt;code&gt;deny&lt;/code&gt; leaves an escape hatch open for the rare file that genuinely needs one, while &lt;code&gt;forbid&lt;/code&gt; slams it shut everywhere it can. Almost every file gets &lt;code&gt;forbid&lt;/code&gt;. A tiny number need the hatch.&lt;/p&gt;
&lt;h2 id="the-files-that-need-the-hatch"&gt;The files that need the hatch
&lt;/h2&gt;&lt;p&gt;The command and provider registries use &lt;code&gt;linkme&lt;/code&gt;&amp;rsquo;s &lt;code&gt;distributed_slice&lt;/code&gt; so backends can register themselves at link time, without &lt;a class="link" href="https://phpboyscout.uk/registering-commands-without-life-before-main/" &gt;life before main&lt;/a&gt;. And the &lt;code&gt;linkme&lt;/code&gt; attribute expands to code carrying a &lt;code&gt;#[link_section]&lt;/code&gt;, which the &lt;code&gt;unsafe_code&lt;/code&gt; lint counts as unsafe. So any file using the attribute, whether it declares a slice or registers into one, can&amp;rsquo;t live under &lt;code&gt;forbid&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the Gitea release backend doing exactly that, from &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-vcs/src/gitea.rs#L21" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-vcs/src/gitea.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#![allow(unsafe_code)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// ...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// Link-time registration entry.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[distributed_slice(RELEASE_PROVIDERS)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;__register_gitea&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ProviderRegistration&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Box&lt;/span&gt;::&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RegisteredProvider&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;source_type&lt;/span&gt;: &lt;span class="s"&gt;&amp;#34;gitea&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;: &lt;span class="nc"&gt;factory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ProviderFactory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That &lt;code&gt;#![allow(unsafe_code)]&lt;/code&gt; isn&amp;rsquo;t there because the backend does anything dangerous. It&amp;rsquo;s there because the registration macro emits a &lt;code&gt;#[link_section]&lt;/code&gt;, and &lt;code&gt;forbid&lt;/code&gt; would, correctly by its own rules, refuse to compile the file.&lt;/p&gt;
&lt;h2 id="where-that-leaves-the-promise"&gt;Where that leaves the promise
&lt;/h2&gt;&lt;p&gt;The guarantee survives, with an exception you can point at. Every production crate forbids unsafe outright. The &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/Cargo.toml#L37-43" target="_blank" rel="noopener"
 &gt;workspace&lt;/a&gt; sits one notch looser at &lt;code&gt;deny&lt;/code&gt;, precisely so the handful of files that use &lt;code&gt;linkme&lt;/code&gt; (and a couple of test files that need Rust 2024&amp;rsquo;s unsafe env mutation) can open a narrow, module-scoped &lt;code&gt;#![allow(unsafe_code)]&lt;/code&gt; with a written reason. The absolutist rule met a macro that writes a &lt;code&gt;link_section&lt;/code&gt; for you. The answer wasn&amp;rsquo;t to drop the rule, it was to keep &lt;code&gt;forbid&lt;/code&gt; everywhere it can hold and clearly label the one or two spots where it can&amp;rsquo;t.&lt;/p&gt;</description></item><item><title>A framework that contains no unsafe</title><link>https://phpboyscout.uk/a-framework-that-contains-no-unsafe/</link><pubDate>Tue, 28 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-framework-that-contains-no-unsafe/</guid><description>&lt;img src="https://phpboyscout.uk/a-framework-that-contains-no-unsafe/cover-a-framework-that-contains-no-unsafe.png" alt="Featured image of post A framework that contains no unsafe" /&gt;&lt;p&gt;&amp;ldquo;It&amp;rsquo;s written in Rust&amp;rdquo; gets thrown around as if it were a memory-safety guarantee. It mostly isn&amp;rsquo;t. Rust is memory-safe by &lt;em&gt;default&lt;/em&gt;, which is a wonderful thing, but the &lt;code&gt;unsafe&lt;/code&gt; keyword exists precisely so any crate, any module, can step outside that default when it needs to. So &amp;ldquo;written in Rust&amp;rdquo; really means &amp;ldquo;mostly safe, probably&amp;rdquo;. rust-tool-base makes the stronger claim about its own code, and gets the compiler to enforce it.&lt;/p&gt;
&lt;h2 id="safe-by-default-is-not-the-same-as-safe"&gt;Safe by default is not the same as safe
&lt;/h2&gt;&lt;p&gt;People reach for Rust because of memory safety, and the reputation is earned. Write ordinary Rust and the compiler will not let you have a use-after-free, a data race, or a buffer overrun. That&amp;rsquo;s the default, and it&amp;rsquo;s a very good default.&lt;/p&gt;
&lt;p&gt;But it&amp;rsquo;s a default, and defaults can be turned off. Rust has an &lt;code&gt;unsafe&lt;/code&gt; keyword precisely so that, when you genuinely need to, you can dereference a raw pointer, call into C, or tell the compiler you&amp;rsquo;ve upheld an invariant it can&amp;rsquo;t check itself. Inside an &lt;code&gt;unsafe&lt;/code&gt; block, the guarantees are yours to maintain, not the compiler&amp;rsquo;s to enforce.&lt;/p&gt;
&lt;p&gt;That keyword has to exist. Some of the most foundational crates in the ecosystem are built on it, carefully. But it means a fact worth being precise about: a project being &amp;ldquo;written in Rust&amp;rdquo; tells you its code is &lt;em&gt;mostly&lt;/em&gt; safe. It does not tell you the project&amp;rsquo;s own code contains &lt;em&gt;no&lt;/em&gt; &lt;code&gt;unsafe&lt;/code&gt;. Those are different claims, and only the second one is a guarantee.&lt;/p&gt;
&lt;p&gt;rust-tool-base makes the second claim about its own code, and has the compiler back it up.&lt;/p&gt;
&lt;h2 id="forbid-not-just-deny"&gt;&lt;code&gt;forbid&lt;/code&gt;, not just &lt;code&gt;deny&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;The mechanism is one line at the top of every crate:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#![forbid(unsafe_code)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;unsafe_code&lt;/code&gt; is a lint, and Rust lints have levels. The interesting choice is &lt;code&gt;forbid&lt;/code&gt; rather than &lt;code&gt;deny&lt;/code&gt;, because the two are not the same strength.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;deny&lt;/code&gt; makes the lint an error. But it&amp;rsquo;s an error a &lt;em&gt;downstream module can locally override&lt;/em&gt;. Anyone can write &lt;code&gt;#[allow(unsafe_code)]&lt;/code&gt; on a function or a block and the &lt;code&gt;deny&lt;/code&gt; is lifted right there. As a policy, &lt;code&gt;deny&lt;/code&gt; is &amp;ldquo;don&amp;rsquo;t do this unless you really mean to&amp;rdquo;, and &amp;ldquo;unless you really mean to&amp;rdquo; is a door.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;forbid&lt;/code&gt; is the strict one. It makes the lint an error &lt;em&gt;and&lt;/em&gt; it makes that error impossible to override from inside the crate. A module cannot &lt;code&gt;#[allow]&lt;/code&gt; its way back out. Once a crate root says &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;, there&amp;rsquo;s no &lt;code&gt;unsafe&lt;/code&gt; anywhere in that crate, and no local exception can be carved out. The compiler simply refuses.&lt;/p&gt;
&lt;p&gt;So every rust-tool-base crate that ships in a built tool forbids &lt;code&gt;unsafe&lt;/code&gt; at its root. Not &amp;ldquo;discourages&amp;rdquo;. Cannot contain it.&lt;/p&gt;
&lt;h2 id="the-one-subtlety"&gt;The one subtlety
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a wrinkle, and it&amp;rsquo;s worth showing rather than hiding, because it&amp;rsquo;s where the design got specific.&lt;/p&gt;
&lt;p&gt;The workspace sets &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/Cargo.toml#L43" target="_blank" rel="noopener"
 &gt;&lt;code&gt;unsafe_code = &amp;quot;deny&amp;quot;&lt;/code&gt;&lt;/a&gt; as the baseline for &lt;em&gt;everything&lt;/em&gt;, including test files. But test code occasionally has a real need for &lt;code&gt;unsafe&lt;/code&gt;. In the 2024 edition, &lt;code&gt;std::env::set_var&lt;/code&gt; became &lt;code&gt;unsafe&lt;/code&gt;, because mutating the process environment isn&amp;rsquo;t thread-safe, and a test that exercises environment-driven configuration has to call it.&lt;/p&gt;
&lt;p&gt;So the split is deliberate. The workspace-wide level is &lt;code&gt;deny&lt;/code&gt;, which a test file can locally &lt;code&gt;#[allow]&lt;/code&gt; when it genuinely needs that one environment call. But every production &lt;code&gt;lib.rs&lt;/code&gt; and &lt;code&gt;main.rs&lt;/code&gt; additionally carries &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;, and &lt;code&gt;forbid&lt;/code&gt; cannot be relaxed. Test scaffolding gets a controlled, visible exception for a specific standard-library call. Shipping code gets none. The guarantee that matters, &amp;ldquo;the code in the binary contains no &lt;code&gt;unsafe&lt;/code&gt;&amp;rdquo;, holds, and the place it&amp;rsquo;s slightly loosened is exactly the place that never reaches a user.&lt;/p&gt;
&lt;h2 id="what-the-guarantee-is-actually-worth"&gt;What the guarantee is actually worth
&lt;/h2&gt;&lt;p&gt;Two things, one for users and one for reviewers.&lt;/p&gt;
&lt;p&gt;For users: an entire family of bug is ruled out of first-party code mechanically. Use-after-free, double-free, data races on shared memory, reading off the end of a buffer. These are the classic memory-safety vulnerabilities, and in a crate that forbids &lt;code&gt;unsafe&lt;/code&gt; they cannot originate, because the constructs that produce them cannot be written. That&amp;rsquo;s not careful coding. It&amp;rsquo;s the compiler refusing to build anything else.&lt;/p&gt;
&lt;p&gt;For reviewers: the cost of an &lt;code&gt;unsafe&lt;/code&gt; block is mostly the review burden it carries. Every one is a spot where a human has to check, by hand, that an invariant holds, and has to re-check it whenever nearby code changes. A crate that forbids &lt;code&gt;unsafe&lt;/code&gt; has zero of those. There&amp;rsquo;s no &lt;code&gt;unsafe&lt;/code&gt; block to audit, ever, because the compiler guarantees there isn&amp;rsquo;t one.&lt;/p&gt;
&lt;p&gt;The promise has a boundary. It covers rust-tool-base&amp;rsquo;s &lt;em&gt;own&lt;/em&gt; code; its dependencies are another matter, and some of them do contain &lt;code&gt;unsafe&lt;/code&gt;, correctly. Keeping that side honest is a different job, done by &lt;a class="link" href="https://phpboyscout.uk/waivers-with-an-expiry-date/" &gt;vetting the dependency tree and gating it in CI&lt;/a&gt;. Within first-party code, though, the guarantee is real, and there&amp;rsquo;s no Go equivalent to it. Go has an &lt;code&gt;unsafe&lt;/code&gt; package, but nothing that lets a codebase prove, to the compiler, that it never touches it.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;Rust is memory-safe by default, but the &lt;code&gt;unsafe&lt;/code&gt; keyword exists so that default can be set aside. &amp;ldquo;Written in Rust&amp;rdquo; therefore does not by itself mean a project&amp;rsquo;s own code contains no &lt;code&gt;unsafe&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;rust-tool-base makes that the stronger claim. Every crate root carries &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;, and &lt;code&gt;forbid&lt;/code&gt;, unlike &lt;code&gt;deny&lt;/code&gt;, cannot be overridden from inside the crate. Test files get a narrow, visible &lt;code&gt;deny&lt;/code&gt;-level exception for the one standard-library call that needs it; shipping code gets none. The payoff is a whole class of memory-safety bug ruled out of first-party code by construction, and not one &lt;code&gt;unsafe&lt;/code&gt; block left for a reviewer to audit.&lt;/p&gt;</description></item><item><title>Reloading config without a restart</title><link>https://phpboyscout.uk/reloading-config-without-a-restart/</link><pubDate>Mon, 27 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/reloading-config-without-a-restart/</guid><description>&lt;img src="https://phpboyscout.uk/reloading-config-without-a-restart/cover-reloading-config-without-a-restart.png" alt="Featured image of post Reloading config without a restart" /&gt;&lt;p&gt;A config file changes. Someone edits a setting, rotates a credential, flips a feature flag. How does the running process find out? For most processes the answer is blunt: it doesn&amp;rsquo;t, until you restart it. For a short-lived CLI that&amp;rsquo;s completely fine. For a long-running service, &amp;ldquo;just restart it&amp;rdquo; is a much bigger ask than it sounds.&lt;/p&gt;
&lt;h2 id="the-default-answer-is-a-restart"&gt;The default answer is a restart
&lt;/h2&gt;&lt;p&gt;Configuration lives in a file. The file changes: someone edits a setting, rotates a credential, flips a feature flag. How does the running process find out?&lt;/p&gt;
&lt;p&gt;Overwhelmingly, the answer is that it doesn&amp;rsquo;t. A process reads its config once, at startup, and that snapshot is frozen for the life of the process. Change the file and nothing happens until you restart, at which point a fresh process reads the fresh file.&lt;/p&gt;
&lt;p&gt;For a short-lived CLI invocation that&amp;rsquo;s completely fine. It reads config, does its job, exits, and the next invocation reads whatever the file says then. But the same frameworks are also used to build long-running services, and for a service &amp;ldquo;just restart it&amp;rdquo; is not the small thing it sounds like.&lt;/p&gt;
&lt;h2 id="what-a-restart-actually-costs"&gt;What a restart actually costs
&lt;/h2&gt;&lt;p&gt;Restarting a long-running service means every open connection drops. Any in-flight request is lost, or has to be retried by whoever sent it. Caches that took real time to warm are cold again. There&amp;rsquo;s a window, short but real, where the service simply isn&amp;rsquo;t serving.&lt;/p&gt;
&lt;p&gt;If the thing you changed was a log level, or a feature flag, or a timeout, you&amp;rsquo;ve paid a disruption wildly out of proportion to the change. And the calculation only gets worse as the service gets more important, because the services you least want to bounce on a whim are exactly the ones that matter most.&lt;/p&gt;
&lt;h2 id="hot-reload-re-read-in-place"&gt;Hot-reload: re-read in place
&lt;/h2&gt;&lt;p&gt;Hot-reload is the alternative, and both go-tool-base and rust-tool-base support it.&lt;/p&gt;
&lt;p&gt;The process doesn&amp;rsquo;t read config once and freeze it. It &lt;em&gt;watches&lt;/em&gt; the config file. When the file changes, it re-reads it, re-applies it, and carries on running. No new process, no dropped connections, no cold start. The change lands in the live process.&lt;/p&gt;
&lt;p&gt;The shape is the same in both frameworks:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A file watcher notices the config file changed. Underneath, this is the operating system&amp;rsquo;s own file-notification facility, &lt;code&gt;inotify&lt;/code&gt; on Linux and its equivalents elsewhere. rust-tool-base reaches it through the &lt;code&gt;notify&lt;/code&gt; crate; go-tool-base, through the watcher built into Viper.&lt;/li&gt;
&lt;li&gt;A debounce step waits for the writes to settle. Saving a file is often several separate operations, and you don&amp;rsquo;t want to reload three times for one edit.&lt;/li&gt;
&lt;li&gt;The config is re-parsed from disk.&lt;/li&gt;
&lt;li&gt;The new config is swapped in atomically.&lt;/li&gt;
&lt;li&gt;Observers are notified, so the subsystems that care can react.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Steps four and five are the ones worth slowing down on, because they&amp;rsquo;re where a naive hot-reload quietly goes wrong.&lt;/p&gt;
&lt;h2 id="the-two-details-that-make-it-safe"&gt;The two details that make it safe
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;The atomic swap.&lt;/strong&gt; You do not mutate the live config object in place. A reader on another thread, partway through reading it, would see a torn mix of old and new values, and that&amp;rsquo;s a genuinely nasty class of bug. Instead the process builds a &lt;em&gt;new&lt;/em&gt;, complete config value and swaps the pointer to it in a single atomic operation. Any reader sees either the entire old config or the entire new one, never a blend. rust-tool-base does this with &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-config/src/watch.rs" target="_blank" rel="noopener"
 &gt;&lt;code&gt;arc-swap&lt;/code&gt;&lt;/a&gt;; go-tool-base does the equivalent. Reads stay cheap and lock-free, and an update is one pointer swap.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The observer notification.&lt;/strong&gt; Re-reading the file isn&amp;rsquo;t the end of the job. Some subsystems have to &lt;em&gt;do something&lt;/em&gt; when config changes: a connection pool resizes, a logger changes level, a rate limiter takes a new ceiling. So a hot-reload system has to let those subsystems subscribe. rust-tool-base hands observers a &lt;code&gt;watch::Receiver&lt;/code&gt;, a channel that always holds the latest value; go-tool-base exposes an &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/config/observer.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Observable&lt;/code&gt; interface&lt;/a&gt;. A subsystem subscribes once and reacts every time config changes, for the life of the process.&lt;/p&gt;
&lt;h2 id="where-this-earns-its-keep-a-kubernetes-pod"&gt;Where this earns its keep: a Kubernetes pod
&lt;/h2&gt;&lt;p&gt;Hot-reload is a nicety on a developer&amp;rsquo;s laptop. Inside a Kubernetes pod it becomes genuinely valuable, and the reason is a neat fit between how Kubernetes delivers config and how a file watcher works.&lt;/p&gt;
&lt;p&gt;In Kubernetes you don&amp;rsquo;t usually bake configuration into the container image. It lives in ConfigMap and Secret objects, and the clean way to consume them is to &lt;em&gt;mount them as volumes&lt;/em&gt;. Mount a ConfigMap as a volume and each key becomes a file in the pod&amp;rsquo;s filesystem.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the part that connects to everything above. When you update that ConfigMap or Secret, Kubernetes does not restart your pod. The kubelet notices the object changed and rewrites the projected files inside the still-running pod. The files on disk change underneath a process that never stopped.&lt;/p&gt;
&lt;p&gt;That file rewrite is exactly the event a hot-reload watcher exists to catch. So the whole chain becomes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You &lt;code&gt;kubectl apply&lt;/code&gt; an updated ConfigMap, or rotate a Secret.&lt;/li&gt;
&lt;li&gt;The kubelet updates the projected files inside the pod.&lt;/li&gt;
&lt;li&gt;The framework&amp;rsquo;s file watcher sees the write.&lt;/li&gt;
&lt;li&gt;The config is re-parsed, swapped in atomically, and observers are notified.&lt;/li&gt;
&lt;li&gt;The new configuration is live, and the pod never cycled.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You&amp;rsquo;ve changed a running service, in a running pod, with no rollout, nothing terminated and recreated, no dropped traffic. Rotate a database credential, raise a log level to debug an incident in progress, flip a feature flag: all of it live. For a service where a restart is the very thing you&amp;rsquo;re trying hard to avoid, the kind of &lt;a class="link" href="https://phpboyscout.uk/lifecycle-management-for-long-running-go-services/" &gt;long-running service&lt;/a&gt; these frameworks are built for, that&amp;rsquo;s the difference between a config change being routine and being an event.&lt;/p&gt;
&lt;h2 id="the-caveats"&gt;The caveats
&lt;/h2&gt;&lt;p&gt;Two things, so this doesn&amp;rsquo;t read as magic.&lt;/p&gt;
&lt;p&gt;First, not everything can be hot-reloaded. Some configuration genuinely needs a restart: the port a server binds to, the size of a thread pool, anything wired up exactly once at process start. Hot-reload covers the large category of settings a subsystem can re-read and re-apply; it doesn&amp;rsquo;t abolish restarts. A config system worth its salt is clear about which settings are live and which are not.&lt;/p&gt;
&lt;p&gt;Second, a Kubernetes gotcha that catches people out. The in-place file update happens for ConfigMaps and Secrets mounted as &lt;em&gt;volumes&lt;/em&gt;. Consume the same ConfigMap as &lt;em&gt;environment variables&lt;/em&gt; instead, and those are fixed when the container starts and never update, short of a restart. If you want hot-reload in a pod, mount config and secrets as files, not env vars. And even with volumes the update isn&amp;rsquo;t instant: the kubelet syncs on a period, around a minute by default, so a reload is &amp;ldquo;within a minute or so&amp;rdquo;, not &amp;ldquo;the moment you hit apply&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;A config file changes, and the default way to pick it up is to restart the process. For a long-running service that restart costs dropped connections, lost work and a cold start, often for a change as small as a log level.&lt;/p&gt;
&lt;p&gt;go-tool-base and rust-tool-base both support hot-reload instead: a file watcher catches the change, the config is re-parsed and swapped in atomically so no reader sees torn state, and observers are notified so subsystems can react, all in a live process. The setting where it pays off most is a Kubernetes pod, where ConfigMaps and Secrets mounted as volumes are rewritten in place by the kubelet and the watcher catches that write directly. Mount them as volumes rather than env vars, allow for the kubelet&amp;rsquo;s sync delay, accept that some settings still need a restart, and within those limits &amp;ldquo;the config changed&amp;rdquo; stops meaning &amp;ldquo;cycle the pod&amp;rdquo;.&lt;/p&gt;</description></item><item><title>Waivers with an expiry date</title><link>https://phpboyscout.uk/waivers-with-an-expiry-date/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/waivers-with-an-expiry-date/</guid><description>&lt;img src="https://phpboyscout.uk/waivers-with-an-expiry-date/cover-waivers-with-an-expiry-date.png" alt="Featured image of post Waivers with an expiry date" /&gt;&lt;p&gt;A vulnerability scanner gives you a yes or a no. Is there a known advisory on a path you actually use? Yes, or no. That&amp;rsquo;s genuinely useful, and you should run one. But it&amp;rsquo;s a snapshot, taken on the day you ask, and supply-chain risk in a framework is a bigger and more ongoing thing than a single yes-or-no can capture.&lt;/p&gt;
&lt;p&gt;So rust-tool-base treats its whole dependency tree as something to have a &lt;em&gt;policy&lt;/em&gt; about, not something to scan and forget.&lt;/p&gt;
&lt;h2 id="a-scanner-answers-one-question"&gt;A scanner answers one question
&lt;/h2&gt;&lt;p&gt;When I &lt;a class="link" href="https://phpboyscout.uk/every-finding-was-the-same-shape/" &gt;had go-tool-base security-audited&lt;/a&gt;, part of the routine was running a vulnerability scanner over the dependencies. Go has a good one. It looks at your dependency graph, cross-references known advisories, and tells you whether any of them reach code you actually call.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s useful and you should do it. But notice the shape of what it gives back: essentially a yes or a no. Either there&amp;rsquo;s a known vulnerability on a reachable path or there isn&amp;rsquo;t. It answers one question, on the day you ask it.&lt;/p&gt;
&lt;p&gt;Supply-chain risk in a framework is broader than that one question, because a framework drags its entire dependency tree into every tool built on it. rust-tool-base treats the whole tree as something to have a &lt;em&gt;policy&lt;/em&gt; about, and the tool for that is &lt;code&gt;cargo-deny&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="a-gate-not-a-scan"&gt;A gate, not a scan
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;cargo-deny&lt;/code&gt; reads a &lt;code&gt;deny.toml&lt;/code&gt; and checks the dependency graph against four kinds of rule.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Licences.&lt;/strong&gt; There&amp;rsquo;s an allowlist: MIT, Apache-2.0, the BSD variants, ISC, a handful of others. Every transitive crate&amp;rsquo;s licence has to be on it. A dependency that pulls in something copyleft, or something with no licence at all, fails the build. You find out the first time it enters the tree, not during a release scramble when someone finally reads the legal implications.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Advisories.&lt;/strong&gt; It checks the RustSec advisory database, and yanked crates are set to &lt;code&gt;deny&lt;/code&gt;, so a dependency that&amp;rsquo;s been pulled from the registry stops CI.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bans.&lt;/strong&gt; Wildcard version requirements (&lt;code&gt;version = &amp;quot;*&amp;quot;&lt;/code&gt;) are denied outright, because a dependency that floats to whatever&amp;rsquo;s newest is a supply-chain hole by construction. Duplicate versions of the same crate get surfaced too.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sources.&lt;/strong&gt; Crates may only come from the official registry. An unknown registry or a stray git dependency is denied. Nothing sneaks in from a URL.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a gate. It encodes, as rules in a file, what the project will and won&amp;rsquo;t accept into its dependency tree, and it enforces them on every build instead of once an audit.&lt;/p&gt;
&lt;h2 id="the-honest-part-is-the-waiver-list"&gt;The honest part is the waiver list
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the thing every real project runs into. Sooner or later there&amp;rsquo;s an advisory you genuinely can&amp;rsquo;t fix this week. It&amp;rsquo;s against a crate three levels down your tree. The fix needs an upstream release that hasn&amp;rsquo;t happened. The crate is scheduled to be reworked two milestones from now anyway. The gate is going to fail, and the work to satisfy it honestly isn&amp;rsquo;t available to you yet.&lt;/p&gt;
&lt;p&gt;The lazy response is a blanket ignore: silence the advisory, move on, forget. Now your gate has a hole in it that nobody remembers opening.&lt;/p&gt;
&lt;p&gt;rust-tool-base&amp;rsquo;s &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/deny.toml#L14" target="_blank" rel="noopener"
 &gt;&lt;code&gt;deny.toml&lt;/code&gt;&lt;/a&gt; does something better. Every waiver in the &lt;code&gt;ignore&lt;/code&gt; list is a documented record. Each one carries a comment that names the crate, traces the &lt;em&gt;exact dependency path&lt;/em&gt; that reaches it, gives the reason, and names the condition that lifts it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;ignore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# `instant` - reached via async-openai -&amp;gt; backoff -&amp;gt; rtb-ai (v0.3).&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;RUSTSEC-2024-0384&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# `paste` - reached via ratatui -&amp;gt; rtb-docs (v0.2) / rtb-tui (v0.4).&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;RUSTSEC-2024-0436&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The file states the policy out loud: &amp;ldquo;Every waiver points at a deferred stub crate that will be reworked before its ship milestone. Lift each waiver when the owning crate lands its v0.1.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Some waivers go further and carry a structured reason field, so the &lt;em&gt;why&lt;/em&gt; travels with the entry rather than living only in a comment above it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;RUSTSEC-2025-0140&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gix-date via gix is a stub dependency; rtb-vcs v0.5 will upgrade&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Read that list and you don&amp;rsquo;t see a project that quietly stopped caring about seven advisories. You see seven advisories the project knows about, can trace, and has tied to a specific milestone. The waiver has an expiry condition. When &lt;code&gt;rtb-vcs&lt;/code&gt; reaches v0.5, that &lt;code&gt;gix&lt;/code&gt; entry is meant to come out, and the comment is the reminder that it should.&lt;/p&gt;
&lt;h2 id="why-this-is-the-bit-to-copy"&gt;Why this is the bit to copy
&lt;/h2&gt;&lt;p&gt;A gate that can&amp;rsquo;t be relaxed is a gate people route around. They&amp;rsquo;ll find the broadest possible ignore and use it, because the alternative is being blocked on someone else&amp;rsquo;s release. The pressure to do that is real, and it&amp;rsquo;s not unreasonable.&lt;/p&gt;
&lt;p&gt;So the design that actually holds up isn&amp;rsquo;t a stricter gate. It&amp;rsquo;s a gate with an honest, structured escape hatch: you &lt;em&gt;can&lt;/em&gt; waive an advisory, but a waiver costs you a documented record with a dependency path and an expiry condition. That price is small enough that nobody routes around it, and high enough that waivers don&amp;rsquo;t accumulate silently. The &lt;code&gt;ignore&lt;/code&gt; list stays readable, and every line in it is something you could defend out loud.&lt;/p&gt;
&lt;p&gt;Supply-chain hygiene framed this way isn&amp;rsquo;t an audit you survive once a year. It&amp;rsquo;s bookkeeping: a ledger of what you accepted, why, and when each exception is due to close. Which, now I write it down, is just the &lt;a class="link" href="https://phpboyscout.uk/introducing-go-tool-base/" &gt;Boy Scout rule&lt;/a&gt; again, pointed at a dependency tree. Leave it tidier than you found it, and write down the bits you couldn&amp;rsquo;t tidy yet.&lt;/p&gt;
&lt;h2 id="where-this-leaves-us"&gt;Where this leaves us
&lt;/h2&gt;&lt;p&gt;A vulnerability scanner answers one question on one day. &lt;code&gt;cargo-deny&lt;/code&gt; is a standing policy gate: licences against an allowlist, advisories and yanked crates denied, wildcard versions banned, sources restricted to the official registry, enforced on every build.&lt;/p&gt;
&lt;p&gt;The part of rust-tool-base&amp;rsquo;s setup worth copying is the waiver list. Every advisory that can&amp;rsquo;t be fixed yet is recorded with its crate, its dependency path, its reason and the milestone that removes it. A waiver is a dated note, not a shrug, and that&amp;rsquo;s what keeps the gate honest enough that nobody actually wants to bypass it.&lt;/p&gt;</description></item><item><title>A builder that won't compile if you forget a field</title><link>https://phpboyscout.uk/a-builder-that-wont-compile-if-you-forget-a-field/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-builder-that-wont-compile-if-you-forget-a-field/</guid><description>&lt;img src="https://phpboyscout.uk/a-builder-that-wont-compile-if-you-forget-a-field/cover-a-builder-that-wont-compile-if-you-forget-a-field.png" alt="Featured image of post A builder that won't compile if you forget a field" /&gt;&lt;p&gt;go-tool-base configures things with functional options, and if you forget a required one, the best case is a runtime failure and the worst case is an empty value sailing silently into everything downstream. Most builder patterns share the same hole. rust-tool-base closes it in a way I find genuinely delightful: the &lt;code&gt;.build()&lt;/code&gt; method simply doesn&amp;rsquo;t &lt;em&gt;exist&lt;/em&gt; until you&amp;rsquo;ve set every required field.&lt;/p&gt;
&lt;h2 id="when-is-a-required-field-actually-required"&gt;When is a required field actually required
&lt;/h2&gt;&lt;p&gt;Every framework has constructors with a mix of required and optional inputs. An &lt;code&gt;Application&lt;/code&gt; in rust-tool-base needs tool metadata and a version. It optionally takes a custom config type, extra commands, feature toggles. The metadata needs a name and a summary; a description and a help channel are optional.&lt;/p&gt;
&lt;p&gt;The interesting question is &lt;em&gt;when&lt;/em&gt; &amp;ldquo;required&amp;rdquo; gets enforced. There are really only two moments available: when the program runs, or when it compiles. Most APIs pick the first without ever framing it as a choice.&lt;/p&gt;
&lt;h2 id="how-go-tool-base-does-it"&gt;How go-tool-base does it
&lt;/h2&gt;&lt;p&gt;go-tool-base uses functional options, the standard Go pattern:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;mytool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;New&lt;/code&gt; takes a variadic list of options and applies them. It&amp;rsquo;s flexible and it reads well. But look at what the &lt;em&gt;type&lt;/em&gt; actually says. &lt;code&gt;New&lt;/code&gt; accepts zero or more options. The signature is satisfied by passing nothing at all. If &lt;code&gt;WithName&lt;/code&gt; is required, nothing in the type system knows that. Forget it and the code compiles cleanly, and you find out when the program runs, or worse, when it doesn&amp;rsquo;t visibly fail but quietly carries an empty name into everything downstream.&lt;/p&gt;
&lt;p&gt;A plain builder is no better here. &lt;code&gt;builder.name(&amp;quot;mytool&amp;quot;).build()&lt;/code&gt; and &lt;code&gt;builder.build()&lt;/code&gt; are both perfectly valid calls as far as the compiler is concerned. The builder &lt;em&gt;hopes&lt;/em&gt; you set the name. It can check at the end and return an error, but that check still happens at runtime.&lt;/p&gt;
&lt;p&gt;In every one of these the required-ness of a field is a fact that lives in documentation and in the author&amp;rsquo;s head, not in the code.&lt;/p&gt;
&lt;h2 id="typestate-putting-required-in-the-type"&gt;Typestate: putting &amp;ldquo;required&amp;rdquo; in the type
&lt;/h2&gt;&lt;p&gt;rust-tool-base builds these with &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-app/src/metadata.rs#L86" target="_blank" rel="noopener"
 &gt;&lt;code&gt;bon&lt;/code&gt;&lt;/a&gt;, and the pattern it generates is a &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;&lt;em&gt;typestate&lt;/em&gt;&lt;/a&gt; builder. The idea is that the builder&amp;rsquo;s type changes as you call it, and that type tracks which required fields you&amp;rsquo;ve set so far.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ToolMetadata&lt;/span&gt;::&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;mytool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;my CLI tool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;ToolMetadata::builder()&lt;/code&gt; returns a builder in a state that records &amp;ldquo;name not set, summary not set&amp;rdquo;. Calling &lt;code&gt;.name(...)&lt;/code&gt; consumes that builder and returns a &lt;em&gt;different type&lt;/em&gt;, one whose state records &amp;ldquo;name set&amp;rdquo;. Calling &lt;code&gt;.summary(...)&lt;/code&gt; does the same for the summary.&lt;/p&gt;
&lt;p&gt;The part that matters is &lt;code&gt;.build()&lt;/code&gt;. It isn&amp;rsquo;t a method on the builder in general. It only exists on the builder type that represents &amp;ldquo;every required field has been set&amp;rdquo;. So this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ToolMetadata&lt;/span&gt;::&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;my CLI tool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;doesn&amp;rsquo;t compile. Not because a runtime check fired, but because in the state &amp;ldquo;name not set&amp;rdquo; there&amp;rsquo;s no &lt;code&gt;.build()&lt;/code&gt; method to call in the first place. The compiler stops you, and the error points straight at the missing &lt;code&gt;.name(...)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Optional fields stay optional. You can call &lt;code&gt;.description(...)&lt;/code&gt; or skip it, and &lt;code&gt;.build()&lt;/code&gt; is reachable either way, because the description was never part of the state that gates it. The required and the optional are genuinely different in the type, which is exactly the distinction the functional-options version could only keep in a comment.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Application::builder()&lt;/code&gt; works the same way. It won&amp;rsquo;t produce an &lt;code&gt;Application&lt;/code&gt; until it has metadata and a version, and &amp;ldquo;won&amp;rsquo;t&amp;rdquo; there means the method is absent, not that a check returns &lt;code&gt;Err&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="why-the-moment-matters"&gt;Why the moment matters
&lt;/h2&gt;&lt;p&gt;Moving the check from run time to compile time changes who finds the mistake, and when.&lt;/p&gt;
&lt;p&gt;A runtime check finds it when that code path executes, which might be in a test, might be in CI, might be on a user&amp;rsquo;s machine at the worst possible moment. A compile-time check finds it the instant you write it, in the editor, before anything has run at all. The same mistake, caught at the cheapest possible point instead of one of the expensive ones.&lt;/p&gt;
&lt;p&gt;It also changes what the API &lt;em&gt;documents about itself&lt;/em&gt;. A functional-options constructor can&amp;rsquo;t tell you, from its signature alone, which options you must pass. A typestate builder can, because the set of methods available to you at each step &lt;em&gt;is&lt;/em&gt; the documentation. You literally cannot reach &lt;code&gt;.build()&lt;/code&gt; without having been walked past every required field on the way.&lt;/p&gt;
&lt;p&gt;This is one of those places where Rust&amp;rsquo;s type system earns its reputation. The builder isn&amp;rsquo;t more careful than the Go version. It&amp;rsquo;s that &amp;ldquo;this field is required&amp;rdquo; stopped being a convention and became something the compiler enforces. (Another entry, if you&amp;rsquo;re keeping score from &lt;a class="link" href="https://phpboyscout.uk/what-survives-a-port/" &gt;the porting post&lt;/a&gt;, in the column of outcomes that survived while the Go mechanism got left behind.)&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;Required fields have to be enforced somewhere. Functional options and ordinary builders enforce them at runtime, if at all, because &lt;code&gt;.build()&lt;/code&gt; is always callable and the type system never learns which inputs were mandatory.&lt;/p&gt;
&lt;p&gt;rust-tool-base uses typestate builders generated by &lt;code&gt;bon&lt;/code&gt;. The builder&amp;rsquo;s type changes as you set fields, and &lt;code&gt;.build()&lt;/code&gt; only exists once every required field is present. Forgetting one is a compile error that names the missing call, not a runtime surprise. The required-versus-optional distinction stops being a comment and becomes part of the type.&lt;/p&gt;</description></item><item><title>Process isolation won't save you from the filesystem</title><link>https://phpboyscout.uk/process-isolation-wont-save-you-from-the-filesystem/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/process-isolation-wont-save-you-from-the-filesystem/</guid><description>&lt;img src="https://phpboyscout.uk/process-isolation-wont-save-you-from-the-filesystem/cover-process-isolation-wont-save-you-from-the-filesystem.png" alt="Featured image of post Process isolation won't save you from the filesystem" /&gt;&lt;p&gt;A test that passed every single time I ran it on its own, and failed maybe one run in five when I ran the whole suite. The failure was always the same: the self-update test downloaded a release archive, went to extract it, and found the archive corrupt. Half-written. As if something had been scribbling in the file while it read it. Something had.&lt;/p&gt;
&lt;h2 id="the-comfort-i-was-leaning-on"&gt;The comfort I was leaning on
&lt;/h2&gt;&lt;p&gt;The self-update tests are heavier than a unit test wants to be. They stand up a fake release, download the artefact, verify its checksum, extract it, swap a binary. Real files, real I/O. So they&amp;rsquo;d been built to run as separate &lt;em&gt;processes&lt;/em&gt;, not just separate threads, each one its own little world.&lt;/p&gt;
&lt;p&gt;And I&amp;rsquo;d quietly filed that under &amp;ldquo;solved&amp;rdquo;. Separate processes don&amp;rsquo;t share an address space. One can&amp;rsquo;t reach into another&amp;rsquo;s memory and corrupt a value mid-read. That whole category of data race, the kind you reach for a mutex to fix, simply can&amp;rsquo;t happen across a process boundary. So I&amp;rsquo;d stopped thinking about concurrency in these tests at all, because I&amp;rsquo;d convinced myself the isolation was total.&lt;/p&gt;
&lt;p&gt;It wasn&amp;rsquo;t total. It was isolation of &lt;em&gt;memory&lt;/em&gt;, and I&amp;rsquo;d let myself hear it as isolation of &lt;em&gt;everything&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="two-processes-one-path"&gt;Two processes, one path
&lt;/h2&gt;&lt;p&gt;The thing two processes very much do still share is the filesystem. And the self-update flow, sensibly, caches its download rather than re-fetching it. The default cache directory is computed from the tool&amp;rsquo;s name and the release version, in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-update/src/flow.rs#L228" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-update/src/flow.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;cache_dir_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;: &lt;span class="kp"&gt;&amp;amp;&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;: &lt;span class="kp"&gt;&amp;amp;&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nc"&gt;PathBuf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;directories&lt;/span&gt;::&lt;span class="n"&gt;ProjectDirs&lt;/span&gt;::&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map_or_else&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;::&lt;span class="n"&gt;env&lt;/span&gt;::&lt;span class="n"&gt;temp_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;to_path_buf&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Read that with two parallel test processes in mind. They&amp;rsquo;re testing the &lt;em&gt;same&lt;/em&gt; tool, against the &lt;em&gt;same&lt;/em&gt; fake release tag. So &lt;code&gt;tool_name&lt;/code&gt; matches and &lt;code&gt;version&lt;/code&gt; matches, which means &lt;code&gt;cache_dir_for&lt;/code&gt; hands both of them the &lt;em&gt;identical path&lt;/em&gt;. Two processes, isolated in every way that involves memory, both downloading and extracting into one shared directory on disk, at the same time. One writes the archive while the other is partway through reading it, and you get exactly the corrupt half-written file the test kept tripping over.&lt;/p&gt;
&lt;p&gt;Process isolation did nothing here, because the contention was never in memory. It was on a path string that came out the same for both of them.&lt;/p&gt;
&lt;h2 id="the-fix-is-to-stop-sharing-the-path"&gt;The fix is to stop sharing the path
&lt;/h2&gt;&lt;p&gt;Once it&amp;rsquo;s framed as &amp;ldquo;they share a path&amp;rdquo;, the fix writes itself: don&amp;rsquo;t share the path. Give each invocation its own cache directory. The updater builder already had the seam for it, and the doc comment now says exactly why it&amp;rsquo;s there, in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-update/src/updater.rs#L396" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-update/src/updater.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// Tools call this when they want isolation per-invocation
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// (e.g. CI runners, tests with parallel processes) or to honour
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// a user-supplied `--cache-dir` flag.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;cache_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;: &lt;span class="nc"&gt;impl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Into&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PathBuf&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nc"&gt;Self&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each test now builds its updater with &lt;code&gt;cache_dir(its_own_tempdir)&lt;/code&gt;, so two parallel processes land on two different directories and never meet. No lock, no serialisation, no clever cross-process file mutex. Just the realisation that the shared thing was a directory, and the cure for shared mutable state is usually to stop sharing it, not to guard it.&lt;/p&gt;
&lt;h2 id="the-fix-that-turned-out-to-be-a-feature"&gt;The fix that turned out to be a feature
&lt;/h2&gt;&lt;p&gt;The part I&amp;rsquo;m quietly pleased about is that this didn&amp;rsquo;t stay a test-only hack. The override I needed to isolate the tests is exactly the override a real tool wants for its own reasons. A CI runner doing self-update wants a writable cache path it controls, not wherever &lt;code&gt;directories-rs&lt;/code&gt; decides the system cache lives. A user might reasonably want to point the whole thing somewhere specific. That&amp;rsquo;s a &lt;code&gt;--cache-dir&lt;/code&gt; flag, and &lt;code&gt;cache_dir()&lt;/code&gt; is precisely the hook you&amp;rsquo;d wire it to.&lt;/p&gt;
&lt;p&gt;So the thing I added to stop a flaky test is the same thing a downstream tool reaches for to expose &lt;code&gt;--cache-dir&lt;/code&gt;. The test forced the seam to exist, and the seam was worth having anyway. I&amp;rsquo;ll take that trade every time over a fix that only the test suite benefits from.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;d treated &amp;ldquo;separate processes&amp;rdquo; as a synonym for &amp;ldquo;can&amp;rsquo;t race&amp;rdquo;, and it isn&amp;rsquo;t. Processes don&amp;rsquo;t share memory, so the memory races are gone. They absolutely still share the filesystem, the network, every named resource the OS will hand to anyone who asks for it by the same name. My two test processes computed the same cache path from the same tool and tag, and raced on the files in it, and no amount of address-space isolation was ever going to touch that.&lt;/p&gt;
&lt;p&gt;Shared mutable state on disk is still shared mutable state. The fix wasn&amp;rsquo;t a bigger hammer, it was giving each process its own directory and letting the isolation I thought I already had actually be true.&lt;/p&gt;</description></item><item><title>Registering commands without life before main</title><link>https://phpboyscout.uk/registering-commands-without-life-before-main/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/registering-commands-without-life-before-main/</guid><description>&lt;img src="https://phpboyscout.uk/registering-commands-without-life-before-main/cover-registering-commands-without-life-before-main.png" alt="Featured image of post Registering commands without life before main" /&gt;&lt;p&gt;I ended the &lt;a class="link" href="https://phpboyscout.uk/what-survives-a-port/" &gt;last post&lt;/a&gt; promising to show how a Rust command registers itself when the language flatly refuses to run any of your code before &lt;code&gt;main()&lt;/code&gt;. This is that post, and it&amp;rsquo;s a lovely example of reaching the same outcome by a completely different road.&lt;/p&gt;
&lt;p&gt;The outcome I wanted to keep is self-registration.&lt;/p&gt;
&lt;h2 id="what-self-registration-buys"&gt;What self-registration buys
&lt;/h2&gt;&lt;p&gt;A command in go-tool-base lives in its own file, and that file puts the command into the framework itself. There&amp;rsquo;s no central list of commands to keep in sync. You add a file, the command appears. You delete the file, it&amp;rsquo;s gone. Nothing else changes.&lt;/p&gt;
&lt;p&gt;That property is worth protecting. The alternative, a hand-maintained registry that every new command has to be threaded into, is exactly the sort of central file that turns into a merge-conflict magnet and quietly falls out of date. So when go-tool-base moved to Rust, self-registration was firmly in the column of things that had to survive.&lt;/p&gt;
&lt;p&gt;The way Go &lt;em&gt;did&lt;/em&gt; it was not.&lt;/p&gt;
&lt;h2 id="how-go-does-it"&gt;How Go does it
&lt;/h2&gt;&lt;p&gt;A Go package can declare an &lt;code&gt;init()&lt;/code&gt; function, and the runtime guarantees every &lt;code&gt;init()&lt;/code&gt; runs before &lt;code&gt;main()&lt;/code&gt; starts. A go-tool-base command file uses this to append itself to a package-level slice:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;DeployCommand&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;By the time &lt;code&gt;main()&lt;/code&gt; runs, every command file&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt; has already fired and the registry slice is populated. It&amp;rsquo;s a tidy trick, and it leans entirely on a Go feature: code that executes before &lt;code&gt;main()&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="rust-doesnt-have-that"&gt;Rust doesn&amp;rsquo;t have that
&lt;/h2&gt;&lt;p&gt;Rust has no &lt;code&gt;init()&lt;/code&gt;. There&amp;rsquo;s no language-blessed phase that runs your code before &lt;code&gt;main()&lt;/code&gt;. This is a deliberate decision, not an oversight. Code running before &lt;code&gt;main()&lt;/code&gt; across many files has no well-defined order, and a startup phase whose ordering you can&amp;rsquo;t see is a classic source of subtle, miserable bugs. Rust closed that door on purpose.&lt;/p&gt;
&lt;p&gt;Which leaves a real question. If nothing runs before &lt;code&gt;main()&lt;/code&gt;, how does a command file insert itself into a registry without a central list editing it in?&lt;/p&gt;
&lt;h2 id="distributed-slices"&gt;Distributed slices
&lt;/h2&gt;&lt;p&gt;The answer is a crate called &lt;code&gt;linkme&lt;/code&gt;, and the mechanism is the &lt;em&gt;linker&lt;/em&gt; rather than a runtime phase.&lt;/p&gt;
&lt;p&gt;You declare a slice the framework will collect into:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[distributed_slice]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;BUILTIN_COMMANDS&lt;/span&gt;: &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;(&lt;code&gt;Box&amp;lt;dyn Command&amp;gt;&lt;/code&gt; is just &amp;ldquo;a pointer to some value that implements the &lt;code&gt;Command&lt;/code&gt; trait, whichever concrete type it turns out to be&amp;rdquo;; the &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;primer&lt;/a&gt; covers it if that&amp;rsquo;s unfamiliar.)&lt;/p&gt;
&lt;p&gt;A command file then contributes one entry to it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;Greet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;impl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Greet&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[distributed_slice(BUILTIN_COMMANDS)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;register_greet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Box&lt;/span&gt;::&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Greet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here&amp;rsquo;s the part that makes it work. The &lt;code&gt;#[distributed_slice]&lt;/code&gt; attribute doesn&amp;rsquo;t generate any code that runs at startup. It places each entry into a dedicated section of the compiled object file. When the linker builds the final binary, it gathers everything in that section and lays it out as one contiguous array. &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-app/src/command.rs#L121" target="_blank" rel="noopener"
 &gt;&lt;code&gt;BUILTIN_COMMANDS&lt;/code&gt;&lt;/a&gt; &lt;em&gt;is&lt;/em&gt; that array.&lt;/p&gt;
&lt;p&gt;So by the time the program exists as a binary on disk, the registry is already assembled. &lt;code&gt;main()&lt;/code&gt; doesn&amp;rsquo;t build it. No &lt;code&gt;init()&lt;/code&gt; builds it. The linker built it, statically, as part of producing the executable. At runtime the framework iterates a slice that was complete before the process ever started.&lt;/p&gt;
&lt;h2 id="what-you-get-from-it"&gt;What you get from it
&lt;/h2&gt;&lt;p&gt;The outcome is the one Go&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt; gave, and then a bit more.&lt;/p&gt;
&lt;p&gt;A command still lives in one file and still self-registers. Adding a command is still adding a file. There&amp;rsquo;s still no central list.&lt;/p&gt;
&lt;p&gt;But there&amp;rsquo;s no startup phase to reason about, because there isn&amp;rsquo;t one. There&amp;rsquo;s no global mutable slice being appended to as &lt;code&gt;init()&lt;/code&gt;s fire, because nothing is appended at runtime; the slice is immutable and finished. There&amp;rsquo;s no ordering question, because the linker isn&amp;rsquo;t running your code, it&amp;rsquo;s collecting data. And it costs nothing at runtime: assembling the registry happened at link time, so program start just reads it.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s the same idea go-tool-base had, expressed by the tool Rust actually gives you. Go reaches the registry through a controlled phase before &lt;code&gt;main()&lt;/code&gt;. Rust reaches it without any phase at all, because the linker did the assembly while the binary was still being built. Two roads, one destination&amp;hellip; which, if you&amp;rsquo;ve been following along, is becoming the whole theme of the Rust side of this project.&lt;/p&gt;
&lt;h2 id="in-short"&gt;In short
&lt;/h2&gt;&lt;p&gt;Self-registration, where a command file inserts itself into the framework with no central list, is a property worth keeping. go-tool-base achieves it with a package-level &lt;code&gt;init()&lt;/code&gt;, leaning on Go&amp;rsquo;s guarantee that such functions run before &lt;code&gt;main()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Rust has no equivalent and wants none, because code running before &lt;code&gt;main()&lt;/code&gt; has no clear ordering. rust-tool-base uses &lt;code&gt;linkme&lt;/code&gt; distributed slices instead: each command is placed into a dedicated linker section, and the linker assembles them into one contiguous, immutable slice as it builds the binary. The registry is complete before the program runs. Same outcome as Go&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt;, with no life before &lt;code&gt;main&lt;/code&gt; required.&lt;/p&gt;</description></item><item><title>Two API decisions that quietly contradict each other</title><link>https://phpboyscout.uk/two-api-decisions-that-quietly-contradict/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/two-api-decisions-that-quietly-contradict/</guid><description>&lt;img src="https://phpboyscout.uk/two-api-decisions-that-quietly-contradict/cover-two-api-decisions-that-quietly-contradict.png" alt="Featured image of post Two API decisions that quietly contradict each other" /&gt;&lt;p&gt;Two design decisions on one enum, each sensible on its own, that would have quietly fought each other if I&amp;rsquo;d let them. I didn&amp;rsquo;t, but only because the second one is easy to get wrong and the compiler wouldn&amp;rsquo;t have said a word either way.&lt;/p&gt;
&lt;h2 id="decision-one-promise-the-list-can-grow"&gt;Decision one: promise the list can grow
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;&lt;code&gt;#[non_exhaustive]&lt;/code&gt;&lt;/a&gt; on the &lt;code&gt;Feature&lt;/code&gt; enum. It tells downstream code it can&amp;rsquo;t match the enum exhaustively, so it has to keep a wildcard arm, which in turn means adding a variant later is a non-breaking, minor-version change. Nobody&amp;rsquo;s &lt;code&gt;match&lt;/code&gt; stops compiling just because the enum grew. The doc comment says exactly that: it &amp;ldquo;keeps variant additions a minor-version change for downstream matchers.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="decision-two-hand-out-the-whole-list"&gt;Decision two: hand out the whole list
&lt;/h2&gt;&lt;p&gt;A convenience &lt;code&gt;all()&lt;/code&gt; returning every variant, because iterating over the lot is something you genuinely want to do. The tempting signature is a fixed-size array, &lt;code&gt;[Feature; 11]&lt;/code&gt;: you know precisely how many there are, so why not put it in the type?&lt;/p&gt;
&lt;h2 id="why-those-two-cant-both-be-true"&gt;Why those two can&amp;rsquo;t both be true
&lt;/h2&gt;&lt;p&gt;The catch is a quirk of Rust that often trips up people arriving from other languages: the length of a fixed-size array is part of its &lt;em&gt;type&lt;/em&gt;. &lt;code&gt;[Feature; 11]&lt;/code&gt;, an array of exactly eleven features, and &lt;code&gt;[Feature; 12]&lt;/code&gt;, exactly twelve, are not one type holding a different number of items the way they might be elsewhere. They are two genuinely different, incompatible types, about as interchangeable as &lt;code&gt;i32&lt;/code&gt; and &lt;code&gt;i64&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So the moment you add a twelfth variant, a fixed-size &lt;code&gt;all()&lt;/code&gt; forces an unhappy choice, and both options are bad. Bump the array to &lt;code&gt;[Feature; 12]&lt;/code&gt; and you break every caller who wrote the old length down. Leave it at &lt;code&gt;11&lt;/code&gt; and the new variant is silently dropped, leaving you a function called &lt;code&gt;all&lt;/code&gt; that doesn&amp;rsquo;t return all of them. Either way the &lt;code&gt;#[non_exhaustive]&lt;/code&gt; promise (adding a variant breaks nobody) is quietly cancelled by a return type that welded today&amp;rsquo;s count into the public API.&lt;/p&gt;
&lt;h2 id="so-all-returns-a-slice"&gt;So &lt;code&gt;all()&lt;/code&gt; returns a slice
&lt;/h2&gt;&lt;p&gt;Which is exactly what it does, and the doc comment spells out why, in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-app/src/features.rs#L59" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-app/src/features.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[non_exhaustive]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Init&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Docs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Mcp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Doctor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Ai&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Telemetry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Changelog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Credentials&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="kp"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;&amp;#39;static&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;Self&lt;/span&gt;::&lt;span class="n"&gt;Init&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;Self&lt;/span&gt;::&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;Self&lt;/span&gt;::&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cm"&gt;/* ...the rest... */&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A slice length is a value, not part of the type. Add a variant, the slice gets one longer, and not a single downstream signature changes. The promise holds.&lt;/p&gt;
&lt;h2 id="the-thing-to-watch-for"&gt;The thing to watch for
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;#[non_exhaustive]&lt;/code&gt; is a promise about the future. A fixed-size array is a fact about the present. You can&amp;rsquo;t keep both at once, and nothing will warn you that you&amp;rsquo;ve contradicted yourself, because each decision is individually fine. The trap is always the second API surface that quietly re-bakes the flexibility the first one promised. When you mark a type &amp;ldquo;free to grow,&amp;rdquo; go and check that nothing in its public interface has secretly written down how big it is today.&lt;/p&gt;</description></item><item><title>What survives a port, and what doesn't</title><link>https://phpboyscout.uk/what-survives-a-port/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/what-survives-a-port/</guid><description>&lt;img src="https://phpboyscout.uk/what-survives-a-port/cover-what-survives-a-port.png" alt="Featured image of post What survives a port, and what doesn't" /&gt;&lt;p&gt;Rebuilding go-tool-base in Rust turned out to be the most honest design review I&amp;rsquo;ve ever sat through, and I didn&amp;rsquo;t have to do anything except keep going. Porting a framework into a language with completely different idioms forces a separation you can&amp;rsquo;t fake: the parts that survive the move are &lt;em&gt;design&lt;/em&gt;, and the parts that don&amp;rsquo;t are just &lt;em&gt;habit&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="two-columns"&gt;Two columns
&lt;/h2&gt;&lt;p&gt;When you port a system between languages that don&amp;rsquo;t share idioms, every piece of it sorts itself into one of two columns, without you having to make the call.&lt;/p&gt;
&lt;p&gt;In the first column is the &lt;em&gt;outcome&lt;/em&gt; a piece of the design produces: every command receives the framework&amp;rsquo;s services, configuration is layered with a fixed precedence, commands register themselves, errors carry guidance to the user. In the second column is the &lt;em&gt;mechanism&lt;/em&gt; that produced that outcome in the original language.&lt;/p&gt;
&lt;p&gt;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 own design than any amount of sitting and staring at it would have been.&lt;/p&gt;
&lt;h2 id="the-container"&gt;The container
&lt;/h2&gt;&lt;p&gt;go-tool-base hands every command a &lt;code&gt;Props&lt;/code&gt; 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 &lt;a class="link" href="https://phpboyscout.uk/props-the-container-that-does-the-heavy-lifting/" &gt;wrote a whole post about it&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;em&gt;outcome&lt;/em&gt; is column one: a command should receive one object, and that object should carry the framework&amp;rsquo;s services so the command doesn&amp;rsquo;t go assembling them itself. That survived. RTB hands every command an &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-app/src/app.rs#L32" target="_blank" rel="noopener"
 &gt;&lt;code&gt;App&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The loosely-typed accessors were column two. In Rust an &lt;code&gt;App&lt;/code&gt; is a plain struct with concrete fields, each one an &lt;code&gt;Arc&amp;lt;T&amp;gt;&lt;/code&gt; 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 &lt;em&gt;is for&lt;/em&gt; survived; the way Go expressed it did not.&lt;/p&gt;
&lt;h2 id="registration"&gt;Registration
&lt;/h2&gt;&lt;p&gt;A go-tool-base command self-registers using a package-level &lt;code&gt;init()&lt;/code&gt; function, which Go runs before &lt;code&gt;main()&lt;/code&gt; and which appends the command to a global slice.&lt;/p&gt;
&lt;p&gt;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&amp;rsquo;s genuinely worth keeping.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;init()&lt;/code&gt; mechanism is column two, and Rust doesn&amp;rsquo;t even offer it: Rust deliberately has no code that runs before &lt;code&gt;main()&lt;/code&gt;. The replacement is link-time registration through distributed slices, which gets its &lt;a class="link" href="https://phpboyscout.uk/registering-commands-without-life-before-main/" &gt;own post next&lt;/a&gt;. Same outcome, no global mutable state, assembled by the linker rather than by a startup phase.&lt;/p&gt;
&lt;h2 id="configuration"&gt;Configuration
&lt;/h2&gt;&lt;p&gt;go-tool-base layers configuration with a precedence: flags over environment over file over defaults. Some of it is read back through key lookups.&lt;/p&gt;
&lt;p&gt;The layering and the precedence are column one. They survived exactly. RTB layers config with the same ordering.&lt;/p&gt;
&lt;p&gt;The key lookups were column two. In Rust the merged configuration is deserialised into &lt;em&gt;your own&lt;/em&gt; &lt;code&gt;serde&lt;/code&gt; 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.&lt;/p&gt;
&lt;h2 id="the-error-path"&gt;The error path
&lt;/h2&gt;&lt;p&gt;go-tool-base routes every error through one handler so presentation is consistent, which I &lt;a class="link" href="https://phpboyscout.uk/errors-that-tell-the-user-what-to-do-next/" &gt;also wrote up&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;One consistent exit for errors is column one. It survived. What didn&amp;rsquo;t survive was the &lt;em&gt;handler&lt;/em&gt;: RTB has no error-handler object at all, because Rust&amp;rsquo;s own return-from-&lt;code&gt;main&lt;/code&gt; convention plus a report hook does the job the handler was built to do. That one has &lt;a class="link" href="https://phpboyscout.uk/errors-without-an-error-handler/" &gt;its own post too&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="what-the-exercise-was-actually-worth"&gt;What the exercise was actually worth
&lt;/h2&gt;&lt;p&gt;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 &lt;code&gt;context.Context&lt;/code&gt; and RTB carries on a &lt;code&gt;CancellationToken&lt;/code&gt;. In every case the &lt;em&gt;thing it achieved&lt;/em&gt; walked across to Rust untouched, and the &lt;em&gt;Go code that achieved it&lt;/em&gt; was left behind.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the useful result. Before this port I couldn&amp;rsquo;t 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 it that day. Now I can, because each one was forced to prove itself by being rebuilt from nothing in a language that flatly wouldn&amp;rsquo;t accept the original. Whatever survived was real. Whatever I had to replace was always replaceable, which means it was never really the point.&lt;/p&gt;
&lt;h2 id="the-upshot"&gt;The upshot
&lt;/h2&gt;&lt;p&gt;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&amp;rsquo;s equivalent.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;Props&lt;/code&gt; bag, its &lt;code&gt;init()&lt;/code&gt; 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&amp;rsquo;t run anything before &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;</description></item><item><title>rust-tool-base: the same idea, in a language that argues back</title><link>https://phpboyscout.uk/rust-tool-base-the-same-idea/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/rust-tool-base-the-same-idea/</guid><description>&lt;img src="https://phpboyscout.uk/rust-tool-base-the-same-idea/cover-rust-tool-base-the-same-idea.png" alt="Featured image of post rust-tool-base: the same idea, in a language that argues back" /&gt;&lt;p&gt;I built &lt;a class="link" href="https://phpboyscout.uk/introducing-go-tool-base/" &gt;go-tool-base&lt;/a&gt; because I was sick of rebuilding the same CLI scaffolding every time I started a new Go tool. You&amp;rsquo;d think that would have taught me a lesson about doing things more than once. Apparently not, because I&amp;rsquo;ve now started building rust-tool-base: the same idea, the same itch, for Rust.&lt;/p&gt;
&lt;p&gt;In my defence, there&amp;rsquo;s method in it.&lt;/p&gt;
&lt;h2 id="the-same-itch-a-different-language"&gt;The same itch, a different language
&lt;/h2&gt;&lt;p&gt;go-tool-base exists because I kept writing the same couple of hundred lines of wiring every time I started a new Go CLI. Config loading, logging setup, an update check, an error path, a help system. None of it was the tool. All of it had to be there before the tool could be.&lt;/p&gt;
&lt;p&gt;Lately I&amp;rsquo;ve been learning Rust, and two things collided. The first is how I tend to learn a language. I&amp;rsquo;ve always picked them up reasonably quickly, and the way I do it isn&amp;rsquo;t with a tutorial that builds a toy, it&amp;rsquo;s by rebuilding something whose shape I already know cold, so that every decision is about &lt;em&gt;the language&lt;/em&gt; rather than &lt;em&gt;the problem&lt;/em&gt;. The second is that every time I started a Rust CLI of any size, I hit the very same gap I&amp;rsquo;d already filled once in Go.&lt;/p&gt;
&lt;p&gt;So rather than learn Rust on a throwaway, I decided to learn it by building rust-tool-base: the same idea, the same niche, for Rust.&lt;/p&gt;
&lt;p&gt;One housekeeping note before the series gets going. You don&amp;rsquo;t need to write Rust to follow it. The posts lean on a handful of language ideas, and rather than stop and re-explain each one mid-flow, I&amp;rsquo;ve gathered them into &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;a short primer&lt;/a&gt;. If a bit of syntax trips you up along the way, that&amp;rsquo;s where to look. If you already write Rust, ignore me and read on.&lt;/p&gt;
&lt;h2 id="the-gap-in-rust"&gt;The gap in Rust
&lt;/h2&gt;&lt;p&gt;The Rust ecosystem has a well-earned reputation for sharp, focused crates and a deliberate shortage of big opinionated frameworks. &lt;code&gt;clap&lt;/code&gt; for argument parsing, &lt;code&gt;figment&lt;/code&gt; for layered config, &lt;code&gt;tracing&lt;/code&gt; for logging, &lt;code&gt;miette&lt;/code&gt; for errors, &lt;code&gt;ratatui&lt;/code&gt; for terminal UI, &lt;code&gt;reqwest&lt;/code&gt; and &lt;code&gt;tokio&lt;/code&gt; underneath. Each of them is genuinely best-in-class.&lt;/p&gt;
&lt;p&gt;What nobody hands you is the assembly. Wiring those into one coherent product, and then adding self-update, AI integration, an MCP server, embedded documentation, credential handling, telemetry and a scaffolder, is real work, and it&amp;rsquo;s the same work on every project.&lt;/p&gt;
&lt;p&gt;The closest existing neighbours stop short of it. &lt;code&gt;cli-batteries&lt;/code&gt; is a thin preamble: argument parsing plus a logging subscriber plus panic and signal handling. &lt;code&gt;starbase&lt;/code&gt; has a proper session and lifecycle model but is CLI-agnostic and shaped around the moonrepo tooling it came from. &lt;code&gt;cargo-dist&lt;/code&gt; and &lt;code&gt;cargo-release&lt;/code&gt; are about release packaging, not the runtime. Good tools, all of them, but none is the opinionated, full-lifecycle, scaffolded base that go-tool-base is in the Go world. That space is empty, and rust-tool-base is built to fill it.&lt;/p&gt;
&lt;h2 id="why-it-is-not-a-port"&gt;Why it is not a port
&lt;/h2&gt;&lt;p&gt;The obvious way to build this would be to open go-tool-base and translate it file by file. I&amp;rsquo;m not doing that, and the reason matters enough that it&amp;rsquo;s the rule the whole project is built around.&lt;/p&gt;
&lt;p&gt;go-tool-base is full of Go. It leans on a &lt;a class="link" href="https://phpboyscout.uk/props-the-container-that-does-the-heavy-lifting/" &gt;&lt;code&gt;Props&lt;/code&gt; struct&lt;/a&gt; that carries the framework&amp;rsquo;s services in loosely-typed fields. It configures things with functional options. It registers commands using package-level &lt;code&gt;init()&lt;/code&gt;. It threads a &lt;code&gt;context.Context&lt;/code&gt; through every call. Those are all good, idiomatic Go. Transliterated into Rust they&amp;rsquo;d become code that argues with the compiler on every single line, because Rust has its own answers to every one of those problems and they are emphatically not the Go answers.&lt;/p&gt;
&lt;p&gt;So rust-tool-base reaches the &lt;em&gt;same outcomes&lt;/em&gt; by Rust&amp;rsquo;s means. Commands still self-register, but through link-time machinery instead of &lt;code&gt;init()&lt;/code&gt;. There&amp;rsquo;s still one context object per command, but it&amp;rsquo;s strongly typed rather than a loosely-keyed bag. Configuration is still layered, but it lands in your own typed struct instead of a string-keyed lookup. Same philosophy, same shape of product, an entirely different ecosystem underneath. The &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/README.md#L9" target="_blank" rel="noopener"
 &gt;README&lt;/a&gt; says it plainly: it&amp;rsquo;s a sibling, not a port.&lt;/p&gt;
&lt;h2 id="why-do-it-twice-at-all"&gt;Why do it twice at all
&lt;/h2&gt;&lt;p&gt;Three reasons, and they reinforce each other.&lt;/p&gt;
&lt;p&gt;The first is plain usefulness. The next time I want a Rust CLI tool, I want the same head start go-tool-base already gives me in Go.&lt;/p&gt;
&lt;p&gt;The second is the learning. Rebuilding a system I understand forces me to meet Rust&amp;rsquo;s idioms where they actually bite, not where a tutorial gently stages them. You learn ownership properly when a real design is pushing back at you.&lt;/p&gt;
&lt;p&gt;The third is the one I didn&amp;rsquo;t expect, and it&amp;rsquo;s the subject of the next post. Building the same framework twice, in two languages, turns out to be the cleanest way to find out which of your original decisions were genuine &lt;em&gt;design&lt;/em&gt; and which were merely &lt;em&gt;idiom&lt;/em&gt;. The design survives the move. The idiom does not. Sorting one from the other has been the most interesting part so far.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;rust-tool-base is the Rust sibling of go-tool-base: the same batteries-included, scaffolded, opinionated CLI framework, aimed at the same gap, which in Rust is the gap between a pile of excellent crates and a coherent product.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s not a port. Transliterating Go idioms into Rust produces code that fights the language, so RTB reaches the same outcomes through Rust&amp;rsquo;s own mechanisms instead. The posts after this one walk through the specific cases: how commands register, how the builder works, how errors are reported, and a few things RTB can do that the Go version structurally can&amp;rsquo;t. First, though, the thing the exercise taught me about my own design.&lt;/p&gt;</description></item><item><title>Just enough Rust to follow along</title><link>https://phpboyscout.uk/just-enough-rust-to-follow-along/</link><pubDate>Tue, 21 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/just-enough-rust-to-follow-along/</guid><description>&lt;img src="https://phpboyscout.uk/just-enough-rust-to-follow-along/cover-just-enough-rust-to-follow-along.png" alt="Featured image of post Just enough Rust to follow along" /&gt;&lt;p&gt;I&amp;rsquo;m about to write a run of posts about building rust-tool-base, and they lean on a handful of Rust ideas that I&amp;rsquo;d otherwise have to keep stopping to explain. So here they are, up front, in one place. You don&amp;rsquo;t need to write Rust to follow the series. You need a feel for maybe six concepts, and this is a quick, friendly tour of them. If you already write Rust, skip it with my blessing.&lt;/p&gt;
&lt;h2 id="ownership-and-borrowing"&gt;Ownership and borrowing
&lt;/h2&gt;&lt;p&gt;This is the one everybody mentions, and the one the whole language is built around. Every value in Rust has exactly one &lt;em&gt;owner&lt;/em&gt;, and when the owner goes away, the value is cleaned up. No garbage collector deciding when, no manual &lt;code&gt;free&lt;/code&gt;. If you want to let another piece of code use a value without handing over ownership, you &lt;em&gt;borrow&lt;/em&gt; it: &lt;code&gt;&amp;amp;thing&lt;/code&gt; lends it out for reading, &lt;code&gt;&amp;amp;mut thing&lt;/code&gt; for writing, and the compiler enforces that you can&amp;rsquo;t, say, change something while someone else is reading it.&lt;/p&gt;
&lt;p&gt;The payoff, and the reason people put up with the up-front fuss, is that an entire family of bug (use-after-free, data races, dangling pointers) becomes a &lt;em&gt;compile&lt;/em&gt; error rather than a 3am one. When a post says something &amp;ldquo;moves&amp;rdquo; or is &amp;ldquo;borrowed&amp;rdquo;, that&amp;rsquo;s all this is.&lt;/p&gt;
&lt;h2 id="traits-are-rusts-interfaces"&gt;Traits are Rust&amp;rsquo;s interfaces
&lt;/h2&gt;&lt;p&gt;A &lt;em&gt;trait&lt;/em&gt; is a named set of methods a type can promise to provide, exactly like an interface in Go or Java. &lt;code&gt;impl Command for Greet { ... }&lt;/code&gt; reads as &amp;ldquo;the &lt;code&gt;Greet&lt;/code&gt; type fulfils the &lt;code&gt;Command&lt;/code&gt; contract.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Two bits of syntax show up a lot. &lt;code&gt;dyn Command&lt;/code&gt; means &amp;ldquo;some value whose concrete type I don&amp;rsquo;t know, but which implements &lt;code&gt;Command&lt;/code&gt;&amp;rdquo;, decided at runtime. And because the compiler needs a known size, you usually see it wrapped: &lt;code&gt;Box&amp;lt;dyn Command&amp;gt;&lt;/code&gt; is &amp;ldquo;a pointer to some &lt;code&gt;Command&lt;/code&gt;, whatever it turns out to be.&amp;rdquo; Whenever the series talks about a registry of &lt;code&gt;Box&amp;lt;dyn Something&amp;gt;&lt;/code&gt;, it just means a list of different types that all satisfy the same trait.&lt;/p&gt;
&lt;h2 id="enums-match-and-non_exhaustive"&gt;Enums, &lt;code&gt;match&lt;/code&gt;, and &lt;code&gt;#[non_exhaustive]&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;A Rust &lt;code&gt;enum&lt;/code&gt; is more than a list of named numbers; it&amp;rsquo;s a proper &amp;ldquo;one of these&amp;rdquo; type, and each variant can carry its own data. You handle one with &lt;code&gt;match&lt;/code&gt;, which is like a &lt;code&gt;switch&lt;/code&gt; that the compiler &lt;em&gt;forces&lt;/em&gt; you to make complete: miss a case and it won&amp;rsquo;t build.&lt;/p&gt;
&lt;p&gt;That completeness is usually a gift, but it&amp;rsquo;s awkward for a library, because adding a new variant would break everyone&amp;rsquo;s &lt;code&gt;match&lt;/code&gt;. The fix is the attribute &lt;code&gt;#[non_exhaustive]&lt;/code&gt;: it tells code outside the library &amp;ldquo;you must keep a catch-all &lt;code&gt;_ =&amp;gt;&lt;/code&gt; arm, because I reserve the right to add variants later.&amp;rdquo; With that in place, growing the enum is a non-breaking change. (One whole post turns on a subtle way to &lt;em&gt;accidentally&lt;/em&gt; cancel that promise.)&lt;/p&gt;
&lt;h2 id="the-type-system-carries-facts-not-just-shapes"&gt;The type system carries facts, not just shapes
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s an idea that surprises people coming from other languages: a Rust type often encodes &lt;em&gt;more&lt;/em&gt; than &amp;ldquo;this is a number&amp;rdquo; or &amp;ldquo;this is a list.&amp;rdquo; The size of a fixed array is part of its type, so &lt;code&gt;[Feature; 11]&lt;/code&gt; and &lt;code&gt;[Feature; 12]&lt;/code&gt; are genuinely different, incompatible types, not one type holding a different count.&lt;/p&gt;
&lt;p&gt;Pushed further, you can make the type track &lt;em&gt;state&lt;/em&gt;. A &amp;ldquo;typestate&amp;rdquo; builder changes type as you call it, so &lt;code&gt;.build()&lt;/code&gt; literally doesn&amp;rsquo;t exist as a method until every required field has been set, and forgetting one is a compile error rather than a runtime surprise. When a post says the compiler &amp;ldquo;won&amp;rsquo;t let you&amp;rdquo; do something, this is usually how: the mistake was made unrepresentable in the types.&lt;/p&gt;
&lt;h2 id="result-and-the--operator"&gt;&lt;code&gt;Result&lt;/code&gt; and the &lt;code&gt;?&lt;/code&gt; operator
&lt;/h2&gt;&lt;p&gt;Rust has no exceptions. A function that can fail returns a &lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;/code&gt;: either &lt;code&gt;Ok(value)&lt;/code&gt; or &lt;code&gt;Err(problem)&lt;/code&gt;, and you can&amp;rsquo;t use the value without acknowledging the error case. Writing that check by hand everywhere would be miserable, so there&amp;rsquo;s a shorthand: the &lt;code&gt;?&lt;/code&gt; operator. &lt;code&gt;let x = thing()?;&lt;/code&gt; means &amp;ldquo;if this returned an error, return it up to my caller right now; otherwise give me the value.&amp;rdquo; Errors travel up the call stack as ordinary return values until something handles them, or until they fall out of &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="crates-the-workspace-and-features"&gt;Crates, the workspace, and features
&lt;/h2&gt;&lt;p&gt;A &lt;em&gt;crate&lt;/em&gt; is Rust&amp;rsquo;s unit of compilation, roughly &amp;ldquo;a library or binary.&amp;rdquo; A &lt;em&gt;workspace&lt;/em&gt; is a bundle of crates built together, which is how rust-tool-base is laid out: &lt;code&gt;rtb-app&lt;/code&gt;, &lt;code&gt;rtb-cli&lt;/code&gt;, &lt;code&gt;rtb-config&lt;/code&gt; and so on, each its own crate. And &lt;em&gt;Cargo features&lt;/em&gt; are compile-time switches declared in &lt;code&gt;Cargo.toml&lt;/code&gt;: turn a feature off and the code it guards, and any dependency it pulled in, is never compiled into your binary at all. Not disabled at runtime; simply absent. That distinction does real work in one of the posts.&lt;/p&gt;
&lt;h2 id="thats-the-toolkit"&gt;That&amp;rsquo;s the toolkit
&lt;/h2&gt;&lt;p&gt;Ownership and borrowing, traits and &lt;code&gt;dyn&lt;/code&gt;, enums and &lt;code&gt;match&lt;/code&gt; and &lt;code&gt;#[non_exhaustive]&lt;/code&gt;, types that carry facts, &lt;code&gt;Result&lt;/code&gt; and &lt;code&gt;?&lt;/code&gt;, and crates with features. Six ideas, and they&amp;rsquo;re enough to read everything else in this series without tripping over the language itself. Where a post needs a seventh thing, it&amp;rsquo;ll explain it in passing. Now, on with the actual building.&lt;/p&gt;</description></item></channel></rss>