Mastering Cargo.toml: Top 9 Tips for Rust Configuration


Master Cargo.toml formatting rules and avoid frustration

Rust Cargo Suprises — Source: https://openai.com/dall-e-2/. All other figures from the author.

In JavaScript and other languages, we call a surprising or inconsistent behavior a “Wat!” [that is, a “What!?”]. For example, in JavaScript, an empty array plus an empty array produces an empty string, [] + [] === "". Wat!

At the other extreme, a language sometimes behaves with surprising consistency. I’m calling that a “Wat Not”.

Rust is generally (much) more consistent than JavaScript. Some Rust-related formats, however, offer surprises. Specifically, this article looks at nine wats and wat nots in Cargo.toml.

Recall that Cargo.toml is the manifest file that defines your Rust project’s configuration and dependencies. Its format, TOML (Tom’s Obvious, Minimal Language), represents nested key/value pairs and/or arrays. JSON and YAML are similar formats. Like YAML, but unlike JSON, Tom designed TOML for easy reading and writing by humans.

This journey of nine wats and wat nots will not be as entertaining as JavaScript’s quirks (thank goodness). However, if you’ve ever found Cargo.toml‘s format confusing, I hope this article will help you feel better about yourself. Also, and most importantly, when you learn the nine wats and wat nots, I hope you will be able to write your Cargo.toml more easily and effectively.

This article is not about “fixing” Cargo.toml. The file format is great at its main purpose: specifying the configuration and dependencies of a Rust project. Instead, the article is about understanding the format and its quirks.

You probably know how to add a [dependencies] section to your Cargo.toml. Such a section specifies release dependencies, for example:

[dependencies]
serde = "1.0"

Along the same lines, you can specify development dependencies with a [dev-dependencies] section and build dependencies with a [build-dependencies] section.

You may also need to set compiler options, for example, an optimization level and whether to include debugging information. You do that with profile sections for release, development, and build. Can you guess the names of these three sections? Is it [profile], [dev-profile] and [build-profile]?

No! It’s [profile.release], [profile.dev], and [profile.build]. Wat?

Would [dev-profile] be better than [profile.dev]? Would [dependencies.dev] be better than [dev-dependencies]?

I personally prefer the names with dots. (In “Wat Not 9”, we’ll see the power of dots.) I am, however, willing to just remember the dependences work one way and profiles work another.

You might argue that dots are fine for profiles, but hyphens are better for dependencies because [dev-dependencies] inherits from [dependencies]. In other words, the dependencies in [dependencies] are also available in [dev-dependencies]. So, does this mean that [build-dependencies] inherits from [dependencies]?

No! [build-dependencies] does not inherit from [dependencies]. Wat?

I find this Cargo.toml behavior convenient but confusing.

You likely know that instead of this:

[dependencies]
serde = { version = "1.0" }

you can write this:

[dependencies]
serde = "1.0"

What’s the principle here? How in general TOML do you designate one key as the default key?

You can’t! General TOML has no default keys. Wat?

Cargo TOML does special processing on the version key in the [dependencies] section. This is a Cargo-specific feature, not a general TOML feature. As far as I know, Cargo TOML offers no other default keys.

With Cargo.toml [features] you can create versions of your project that differ in their dependences. Those dependences may themselves differ in their features, which we’ll call sub-features.

Here we create two versions of our project. The default version depends on getrandom with default features. The wasm version depends on getrandom with the js sub-feature:

[features]
default = []
wasm = ["getrandom-js"]

[dependencies]
rand = { version = "0.8" }
getrandom = { version = "0.2", optional = true }

[dependencies.getrandom-js]
package = "getrandom"
version = "0.2"
optional = true
features = ["js"]

In this example, wasm is a feature of our project that depends on dependency alias getrandom-rs which represents the version of the getrandom crate with the js sub-feature.

So, how can we give this same specification while avoiding the wordy [dependencies.getrandom-js] section?

In [features], replace getrandom-js" with "getrandom/js". We can just write:

[features]
default = []
wasm = ["getrandom/js"]

[dependencies]
rand = { version = "0.8" }
getrandom = { version = "0.2", optional = true }

Wat!

In general, in Cargo.toml, a feature specification such as wasm = ["getrandom/js"] can list

  • other features
  • dependency aliases
  • dependencies
  • one or more dependency “slash” a sub-feature

This is not standard TOML. Rather, it is a Cargo.toml-specific shorthand.

Bonus: Guess how you’d use the shorthand to say that your wasm feature should include getrandom with two sub-features: js and test-in-browser?

Answer: List the dependency twice.

wasm = ["getrandom/js","getrandom/test-in-browser"]

We’ve seen how to specify dependencies for release, debug, and build.

[dependencies]
#...
[dev-dependencies]
#...
[build-dependencies]
#...

We’ve seen how to specify dependencies for various features:

[features]
default = []
wasm = ["getrandom/js"]

How would you guess we specify dependences for various targets (e.g. a version of Linux, Windows, etc.)?

We prefix [dependences] with target.TARGET_EXPRESSION, for example:

[target.x86_64-pc-windows-msvc.dependencies]
winapi = { version = "0.3.9", features = ["winuser"] }

Which, by the rules of general TOML means we can also say:

[target]
x86_64-pc-windows-msvc.dependencies={winapi = { version = "0.3.9", features = ["winuser"] }}

Wat!

I find this prefix syntax strange, but I can’t suggest a better alternative. I do, however, wonder why features couldn’t have been handle the same way:

# not allowed
[feature.wasm.dependencies]
getrandom = { version = "0.2", features=["js"]}

This is our first “Wat Not”, that is, it is something that surprised me with its consistency.

Instead of a concrete target such as x86_64-pc-windows-msvc, you may instead use a cfg expression in single quotes. For example,

[target.'cfg(all(windows, target_arch = "x86_64"))'.dependencies]

I don’t consider this a “wat!”. I think it is great.

Recall that cfg, short for “configuration”, is the Rust mechanism usually used to conditionally compile code. For example, in our main.rs, we can say:

if cfg!(target_os = "linux") {
println!("This is Linux!");
}

In Cargo.toml, in target expressions, pretty much the whole cfg mini-language is supported.

all(), any(), not()
target_arch
target_feature
target_os
target_family
target_env
target_abi
target_endian
target_pointer_width
target_vendor
target_has_atomic
unix
windows

The only parts of the cfg mini-language not supported are (I think) that you can’t set a value with the --cfg command line argument. Also, some cfg values such as test don’t make sense.

Recall from Wat 1 that you set compiler options with [profile.release], [profile.dev], and [profile.build]. For example:

[profile.dev]
opt-level = 0

Guess how you set compiler options for a specific target, such as Windows? Is it this?

[target.'cfg(windows)'.profile.dev]
opt-level = 0

No. Instead, you create a new file named .cargo/config.toml and add this:

[target.'cfg(windows)']
rustflags = ["-C", "opt-level=0"]

Wat!

In general, Cargo.toml only supports target.TARGET_EXPRESSION as the prefix of dependency section. You may not prefix a profile section. In .cargo/config.toml, however, you may have [target.TARGET_EXPRESSION] sections. In those sections, you may set environment variables that set compiler options.

Cargo.toml supports two syntaxes for lists:

This example uses both:

[package]
name = "cargo-wat"
version = "0.1.0"
edition = "2021"

[dependencies]
rand = { version = "0.8" }
# Inline array 'features'
getrandom = { version = "0.2", features = ["std", "test-in-browser"] }

# Table array 'bin'
[[bin]]
name = "example"
path = "src/bin/example.rs"

[[bin]]
name = "another"
path = "src/bin/another.rs"

Can we change the table array to an inline array? Yes!

# Inline array 'bin'
bins = [
{ name = "example", path = "src/bin/example.rs" },
{ name = "another", path = "src/bin/another.rs" },
]

[package]
name = "cargo-wat"
version = "0.1.0"
edition = "2021"

[dependencies]
rand = { version = "0.8" }
# Inline array 'features'
getrandom = { version = "0.2", features = ["std", "test-in-browser"] }

Can we change the inline array of features into a table array?

No. Inline arrays of simple values (here, strings) cannot be represented as table arrays. However, I consider this a “wat not”, not a “wat!” because this is a limitation of general TOML, not just of Cargo.toml.

Aside: YAML format, like TOML format, offers two list syntaxes. However, both of YAMLs two syntaxes work with simple values.

Here is a typical Cargo.toml. It mixes section syntax, such as [dependences] with inline syntax such as getrandom = {version = "0.2", features = ["std", "test-in-browser"]}.

[package]
name = "cargo-wat"
version = "0.1.0"
edition = "2021"

[dependencies]
rand = "0.8"
getrandom = { version = "0.2", features = ["std", "test-in-browser"] }

[target.x86_64-pc-windows-msvc.dependencies]
winapi = { version = "0.3.9", features = ["winuser"] }

[[bin]]
name = "example"
path = "src/bin/example.rs"

[[bin]]
name = "another"
path = "src/bin/another.rs"

Can we re-write it to be 100% inline? Yes.

package = { name = "cargo-wat", version = "0.1.0", edition = "2021" }

dependencies = { rand = "0.8", getrandom = { version = "0.2", features = [
"std",
"test-in-browser",
] } }

target = { 'cfg(target_os = "windows")'.dependencies = { winapi = { version = "0.3.9", features = [
"winuser",
] } } }

bins = [
{ name = "example", path = "src/bin/example.rs" },
{ name = "another", path = "src/bin/another.rs" },
]

We can also re-write it with maximum sections:

[package]
name = "cargo-wat"
version = "0.1.0"
edition = "2021"

[dependencies.rand]
version = "0.8"

[dependencies.getrandom]
version = "0.2"
features = ["std", "test-in-browser"]

[target.x86_64-pc-windows-msvc.dependencies.winapi]
version = "0.3.9"
features = ["winuser"]

[[bin]]
name = "example"
path = "src/bin/example.rs"

[[bin]]
name = "another"
path = "src/bin/another.rs"

Finally, let’s talk about dots. In TOML, dots are used to separate keys in nested tables. For example, a.b.c is a key c in a table b in a table a. Can we re-write our example with “lots of dots”? Yes:

package.name = "cargo-wat"
package.version = "0.1.0"
package.edition = "2021"
dependencies.rand = "0.8"
dependencies.getrandom.version = "0.2"
dependencies.getrandom.features = ["std", "test-in-browser"]
target.x86_64-pc-windows-msvc.dependencies.winapi.version = "0.3.9"
target.x86_64-pc-windows-msvc.dependencies.winapi.features = ["winuser"]
bins = [
{ name = "example", path = "src/bin/example.rs" },
{ name = "another", path = "src/bin/another.rs" },
]

I appreciate TOML’s flexibility with respect to sections, inlining, and dots. I count that flexibility as a “wat not”. You may find all the choices it offers confusing. I, however, like that Cargo.toml lets us use TOML’s full power.

Cargo.toml is an essential tool in the Rust ecosystem, offering a balance of simplicity and flexibility that caters to both beginners and seasoned developers. Through the nine wats and wat nots we’ve explored, we’ve seen how this configuration file can sometimes surprise with its idiosyncrasies and yet impress with its consistency and power.

Understanding these quirks can save you from potential frustrations and enable you to leverage Cargo.toml to its fullest. From managing dependencies and profiles to handling target-specific configurations and features, the insights gained here will help you write more efficient and effective Cargo.toml files.

In essence, while Cargo.toml may have its peculiarities, these characteristics are often rooted in practical design choices that prioritize functionality and readability. Embrace these quirks, and you’ll find that Cargo.toml not only meets your project’s needs but also enhances your Rust development experience.

Please follow Carl on Medium. I write on scientific programming in Rust and Python, machine learning, and statistics. I tend to write about one article per month.



Source link

Be the first to comment

Leave a Reply

Your email address will not be published.


*