latest post

It's a library AND a binary

by Gankra, 21 March 2024 | Permalink | RSS

dark 🌙

Here's a cute Rust programming trick that a lot of people get really excited about when they learn about it: a Cargo package can contain both a lib.rs and a main.rs, making it both a library AND a binary! All you need to do is make both files, and it Just Works.

Whether your primary goal is to make a library or a CLI application, being able to expose that core functionality in the other form always has its appeal, so, super cool feature!

...unfortunately the feature doesn't really work the way you want, and if you try to use it you're going to make someone sad.

The root problem is that a library and an application have different needs. Unless you're going out of your way to prove a point, you're gonna want your app to have additional dependencies to handle basic user interfaces and I/O.

I know basically every CLI I make invariably pulls in things like clap and tracing-subscriber which have no business being part of a library. These kinds of dependencies are "decisions" that ideally only applications should make.

So immediately we see we shouldn't do this because it's all the same package and therefore all the same dependencies, which is just, wrong.

But What About Features

Ah but you all know I'm being a fool! This is why Cargo packages can declare optional dependencies which get enabled by custom features!

Indeed, we can define our package with a "cli" feature that enables "clap" and "tracing-subscriber":

[package]name = "aria-of-borrow"version = "0.1.0"edition = "2021"license = "MIT OR Apache-2.0"description = "It's a library AND a binary, but at what cost?"repository = "https://github.com/Gankra/aria-of-borrow"[[bin]]name = "aria-of-borrow"[features]# feature to enable the CLIcli = ["clap", "tracing-subscriber"][dependencies]# library depstracing = "0.1.40"# cli depsclap = { version = "4.5.3", features = ["derive"], optional = true }tracing-subscriber = { version = "0.3.18", optional = true }

Except... when we try to build or publish this, the main.rs freaks out that dependencies like clap are missing, since cli is disabled by default! So that didn't really solve our problem.

The False Prophet: required-features

I know you've read Cargo's docs and found the bin.required-features setting, which lets us say that our binary requires the cli feature. Just a small tweak:

[[bin]]name = "aria-of-borrow"required-features = ["cli"]

And indeed, now cargo build and cargo publish work!

Let's install that cool new CLI!

$ cargo install [email protected]
    Updating crates.io index
  Installing aria-of-borrow v0.2.0
  ...
   Compiling aria-of-borrow v0.2.0
    Finished release [optimized] target(s) in 5.03s
warning: none of the package's binaries are available for install using the selected features
  bin "aria-of-borrow" requires the features: `cli`
Consider enabling some of the needed features by passing, e.g., `--features="cli"`

Nice!

$ aria-of-borrow
aria-of-borrow : The term 'aria-of-borrow' is not recognized as the name of a cmdlet, function, script file, or
operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try
again.

Wait. Oh you expected me to actually read the output of cargo install? Let's go back a step:

warning: none of the package's binaries are available for install using the selected features
  bin "aria-of-borrow" requires the features: `cli`
Consider enabling some of the needed features by passing, e.g., `--features="cli"`

Yes indeed what we have just created is a CLI that when installed does a full build and then... fails at the end, giving only a warning. But at least the warning tells us how to make the binaries actually exist, we just need to explicitly enable the cli feature!

$ cargo install [email protected] --features=cli
    Updating crates.io index
  Installing aria-of-borrow v0.2.0
  ...
   Compiling aria-of-borrow v0.2.0
    Finished release [optimized] target(s) in 7.21s
  Installing C:\Users\aria\.cargo\bin\aria-of-borrow.exe
   Installed package `aria-of-borrow v0.2.0` (executable `aria-of-borrow.exe`)

Yay!

$ aria-of-borrow
alas, a cruel fate

$ aria-of-borrow --cool
wow!!! alas, a cruel fate 🛹

Perfect. The CLI that is. The cargo install experience... is bad.

The issue here is that Cargo's required-features setting doesn't do what anyone thinks it does. It doesn't tell Cargo to enable features when you want a binary, it tells it to disable binaries when the features aren't enabled.

This... makes sense if you think through all the interactions and things you'd want this feature to do... but in a vacuum for the most obvious usecase it's just not doing what you want.

The Prodigal Son: default-features

Of course we all know we can make installing better by making the cli feature be enabled by default with Cargo's features.default setting. Just another small tweak and:

[[bin]]name = "aria-of-borrow"required-features = ["cli"][features]# feature to enable the CLIdefault = ["cli"]cli = ["clap", "tracing-subscriber"]
$ cargo install [email protected]
    Updating crates.io index
  Installing aria-of-borrow v0.3.0
  ...
   Compiling aria-of-borrow v0.3.0
    Finished release [optimized] target(s) in 7.21s
  Installing C:\Users\aria\.cargo\bin\aria-of-borrow.exe
   Installed package `aria-of-borrow v0.3.0` (executable `aria-of-borrow.exe`)

Great! Perfect UX!

...except now we've trashed the UX of someone cargo adding our library:

$ cargo add aria-of-borrow
    Updating crates.io index
      Adding aria-of-borrow v0.3.0 to dependencies.
             Features:
             + clap
             + cli
             + tracing-subscriber
    Updating crates.io index

Uh oh, we just accidentally made some random library inherit our copies of clap and tracing-subscriber! This one is perhaps less brutal in that everything works but it's rude and easy to miss! Everyone who depends on our library now needs to know to always set no-default-features.

$  cargo add aria-of-borrow --no-default-features
    Updating crates.io index
      Adding aria-of-borrow v0.3.0 to dependencies.
             Features:
             - clap
             - cli
             - tracing-subscriber

...which is especially harsh if our library legitimately wants to have some default features of its own, because now they need to know to enable all those and aaaAaaAAAAAAAA!!!!

The True Path: Two Packages

As far as I know there's no winning here, every path leads to sadness for one kind of user or another.

But you know what works perfectly fine and great?

Just. Making. Two. Packages.

That's what we did in cargo-dist, that's what we did in axoupdater. It's this incredible technology that lets you specify different features and dependencies because they have different config files and names!

Of course, this is a sad conclusion, we all know. The Cargo team is well-aware of it, and Folks have a few proposals in the pipeline to make this stuff a little bit better. But really, you don't need to be so cute. Just make two packages!

Well I guess you can also decide that cargo install is for chumps, and use cargo-dist's feature settings to enable the cli feature for your prebuilt binaries. It's something that cargo-dist-itself uses to enable msrv-breaking features for its own prebuilt binaries. But genuinely we like cargo install working properly and recommend you just make two packages.