Skip to content
← All articles

Cargo.toml essentials

The manifest is the interface between your code and the whole ecosystem, and most of its fields are never explained. Including the one whose default is genuinely hostile.

Everything you have written on this site compiles without a manifest, because it is one file with no dependencies. Every real project has one, and it is the first thing a reviewer reads.

[package]

[package]
name = "image-proc"
version = "0.3.1"
edition = "2024"
rust-version = "1.85"
description = "Fast image preprocessing"
license = "MIT OR Apache-2.0"
repository = "https://github.com/you/image-proc"

name, version and edition are the load-bearing three.

edition defaults to 2015 if you omit it. This is a hostile default, kept for compatibility with manifests written before editions existed. A 2015 crate needs extern crate, has different closure-capture and dyn-syntax rules, and lacks TryFrom in the prelude. cargo new always writes it; hand-written manifests sometimes do not.

rust-version — your MSRV, the minimum supported Rust version — used to be documentation. It is now load-bearing: the edition-2024 resolver is MSRV-aware, and given a choice it will pick an older version of a dependency rather than one whose rust-version your toolchain cannot meet. Set it honestly; setting it too low is a lie your CI will not catch.

crates.io additionally requires description and license (or license-file) before it will accept a publish. clippy::cargo_common_metadata nags about the rest — repository, keywords, categories, readme — and it is a fair nag: those fields are the difference between a crate people can evaluate and one they scroll past.

💡A crate you maintain has been published for a year. You want to stop shipping the 40 MB of test fixtures that are in the repository. What are your options and what is the trap? click to reveal

Two fields, and they are mutually exclusive: include lists what goes in the package, exclude lists what stays out. You may set one or the other, never both.

include = ["src/**/*.rs", "Cargo.toml", "README.md", "LICENSE-*"]

The trap is that neither is set by default, so the default behaviour is “everything that is not gitignored”, and that is how crates end up shipping fixtures, benchmark data and a .png of the logo at four resolutions. Every cargo add of your crate then downloads all of it.

cargo package --list shows you exactly what would be included. Run it once before your first publish, and again the day you add a fixtures directory.

The dependency tables

[dependencies]
serde = { version = "1", features = ["derive"] }
rand = "0.8"

[dev-dependencies]
criterion = "0.5"

[build-dependencies]
cc = "1"

[target.'cfg(unix)'.dependencies]
nix = "0.29"
  • [dependencies] — available to src/, and propagated to your consumers as part of your dependency graph.
  • [dev-dependencies] — available to tests, examples and benchmarks only. Not usable from src/, and not propagated to anyone who depends on you. This is why a test helper crate does not become your users’ problem.
  • [build-dependencies] — available to build.rs only, and compiled for the host, not the target.
  • Target-conditional tables use the same cfg predicate language from 10.12, which is a nice payoff: cfg(unix), cfg(target_os = "windows"), cfg(any(…)) all work exactly as you implemented them.

Renaming is done with package:

[dependencies]
rand_old = { package = "rand", version = "0.7" }
rand = "0.8"

Two major versions of the same crate, side by side, under two names. This is legal and occasionally necessary — and clippy::multiple_crate_versions will point out when it happened by accident rather than on purpose, which is far more common.

[lints], and the priority field

Since Rust 1.74 lint configuration belongs in the manifest rather than in #![deny(...)] attributes at the top of lib.rs:

[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
all = { level = "deny", priority = -1 }
needless_range_loop = "allow"

That priority = -1 is the part nobody explains. Within a table, entries are applied in priority order, low to high, and equal priorities are ambiguous. So whenever you enable a group and then override a member of it, the group needs a lower priority than the override — otherwise Cargo cannot tell which you meant, and the group may win.

Cargo.lock

Commit it. Yes, for libraries too.

The old advice was “binaries commit the lockfile, libraries do not”, on the grounds that a library’s lockfile is ignored by its consumers. That is still true — but the lockfile is also what makes your own CI reproducible, and the current official guidance is to commit it for everything. A library’s users are unaffected; a library’s maintainers get a build that does not spontaneously change on a Tuesday.

Lints about the manifest itself

Clippy has a whole cargo group, off by default and irrelevant on this site because there is no manifest, but worth knowing exists:

lint catches
cargo_common_metadata missing description, license, repository, …
wildcard_dependencies foo = "*" — a requirement with no bound
multiple_crate_versions two majors of one crate in the graph
negative_feature_names no-std, disable-logging — features that subtract
redundant_feature_names use-serde, with-rayon — noise in every name

negative_feature_names is the one that matters most, and item 10.16 explains why: features unify by union across the whole graph, so a feature that removes behaviour is a build failure waiting for a third party to trigger it.