RustArena

Designing a library crate

Most Rust code at work lives in libraries that other code depends on. A library's public API is a promise: once someone builds on it, every name, signature and error variant is hard to change. So teams work to get it right: types that make mistakes impossible, errors callers can act on, documentation whose examples are tested, and version numbers that tell the truth.

You'll finish semver-lite, a library that parses, compares and matches semantic versions (the ^1.2 rules Cargo itself uses), and a small semver command-line tool built on it, in one Cargo workspace.

You'll practice: workspaces, API design (newtypes, builders, impl Trait, AsRef), typed errors with thiserror, rustdoc and doc tests, cargo features, semver, and packaging for crates.io.

The workspace

05-library/
  Cargo.toml        [workspace]: the members, plus settings and dependencies they share
  semver-lite/      the library (the product)
  semver-cli/       the `semver` binary: reads arguments, calls the library, prints

A workspace builds several packages with one Cargo.lock and one target/ folder. Real projects (ripgrep, tokio, serde) split this way so the library can be tested and reused without the binary, and the binary stays thin. Shared settings go in [workspace.package] and [workspace.dependencies], and members inherit them with edition.workspace = true.

cd projects/05-library
cargo build --workspace            # everything
cargo test -p semver-lite          # one member (-p = --package)
cargo run -p semver-cli -- sort 1.10.0 1.2.0 v1.2.0-rc.1

The CLI depends on the library with path = "semver-lite", version = "0.1.0": the path is used here, and the version is what the dependency becomes if the CLI is ever published (crates.io doesn't allow path-only dependencies).

Background

API design: make the right thing easy

Typed errors with thiserror

Applications report errors to people, so anyhow (project 04) is fine there. Libraries report errors to code, so they return an enum the caller can match on:

#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    #[error("invalid version number \"{part}\"")]
    InvalidNumber { part: String },
    #[error("invalid version requirement \"{req}\"")]
    InvalidReq { req: String, source: Box<Error> },   // a field named `source` is the cause
}

thiserror writes the Display and std::error::Error impls, including source(), so a program can print the whole chain (try semver match "^1.x" 1.0.0). #[non_exhaustive] makes users add a _ arm when they match, so a new variant later isn't a breaking change.

Documentation is part of the API

/// documents the next item and //! the enclosing one (the crate, at the top of lib.rs). Code blocks in docs are compiled and run by cargo test, so examples can't silently rot. Inside them, lines starting with # are hidden from the rendered page; # Ok::<(), semver_lite::Error>(()) at the end lets an example use ?. Conventional sections are # Examples, # Errors (when a function returns Result) and # Panics, and [`Version`] links to an item. #![deny(missing_docs)] at the top of lib.rs makes any undocumented public item a compile error. Read the result with cargo doc --open.

Optional features

Not every user wants serde. An optional dependency plus a feature lets them choose:

[features]
serde = ["dep:serde"]

[dependencies]
serde = { version = "1", optional = true }

Code that needs it goes behind #[cfg(feature = "serde")] (a whole module is easiest). Features must be additive: turning one on may add API, never remove or change it, because Cargo unifies features across the whole dependency graph. Test both ways (cargo test and cargo test --all-features), as the grader does.

Semver for your own crate

MAJOR.MINOR.PATCH: bump PATCH for fixes, MINOR for new API, MAJOR for breaking changes. Breaking includes removing or renaming a public item, changing a signature, adding a variant to an exhaustive enum, and adding a field to a struct with public fields (it breaks struct literals and patterns). Before 1.0, Cargo treats the first non-zero number as the major one: ^0.2.3 means >=0.2.3, <0.3.0. You are about to implement exactly that rule.

Ready for crates.io

crates.io needs a description, a license (an SPDX expression like MIT OR Apache-2.0) and a link (repository). Good crates also set readme, keywords, categories and rust-version (the oldest Rust they support). cargo package --list shows exactly which files would be uploaded, and cargo package builds the .crate and compiles it in isolation, which catches files you forgot to include. Don't run cargo publish for this project: published versions are permanent.

Your tasks

  1. Errors (semver-lite/src/error.rs): derive thiserror::Error, write the messages listed in the comments, add #[non_exhaustive].
  2. Prerelease (prerelease.rs): new (validation) and Ord (precedence).
  3. Version and Parser (version.rs, parser.rs): parsing, ordering, Display, bumps. Now cargo test -p semver-lite --test api and cargo test -p semver-cli pass.
  4. Documentation: add #![deny(missing_docs)] and crate docs to lib.rs, then document every public item, including req.rs (written by a colleague, undocumented). At least 6 examples in total; cargo test --doc -p semver-lite runs them.
  5. The serde feature: add it to semver-lite/Cargo.toml and implement Serialize/Deserialize for Version as a string in a #[cfg(feature = "serde")] module. Run cargo test -p semver-lite --features serde.
  6. Metadata: fill in [workspace.package] and semver-lite/Cargo.toml, write semver-lite/README.md (what crates.io shows), then run cargo package -p semver-lite --list and cargo package -p semver-lite --allow-dirty.
  7. Grade (bash ../../tools/grade.sh 05-library), then commit and push.

What the grader checks

Hints

Going further