Project: a multi-crate shop
Project structure explained workspaces, features and lints one at a time. This page uses all three on one small program. The shop keeps a catalog of products, answers requests such as GET TEA and TOTAL TEA:2 MUG, and runs from the command line. It is split into four crates so that each one has a single job and depends only on the crates below it.
RememberPut the types and traits everyone shares in the bottom crate, which depends on nothing. Put each implementation in its own crate, and put the choice of implementation in the program at the top.
The sessions below are real runs in the course image (Rust 1.99.1). The first build needs network to read the crates.io index, and the first build with the file feature on downloads serde_json and the four crates under it. Cargo.lock is committed, so a later build gets the same versions. Paths in the output are shown as /home/you/shop.
shop/ ├── crates/ │ ├── api/ │ │ ├── src/ │ │ │ └── lib.rs requests in, statuses out │ │ └── Cargo.toml │ ├── cli/ │ │ ├── src/ │ │ │ └── main.rs the program, named shop │ │ ├── tests/ │ │ │ └── script.rs runs the real program │ │ └── Cargo.toml │ ├── core/ │ │ ├── src/ │ │ │ ├── catalog.rs the Catalog trait │ │ │ ├── error.rs │ │ │ ├── lib.rs │ │ │ ├── money.rs │ │ │ └── product.rs │ │ └── Cargo.toml │ └── storage/ │ ├── src/ │ │ ├── file.rs only built with the file feature │ │ ├── lib.rs │ │ └── memory.rs │ ├── tests/ │ │ └── contract.rs one set of checks, run on each backend │ └── Cargo.toml ├── Cargo.lock committed ├── Cargo.toml the workspace: members, shared package settings, dependencies and lints └── rustfmt.toml formatter settings
The workspace file
Section titled “The workspace file”The root Cargo.toml has no [package]. It lists the members, and it holds the three things the members share, so each is written once.
[workspace]members = ["crates/*"]resolver = "3"
[workspace.package]version = "0.1.0"edition = "2024"rust-version = "1.85"license = "MIT OR Apache-2.0"
[workspace.dependencies]serde_json = "1.0"shop-core = { path = "crates/core", version = "0.1.0" }shop-storage = { path = "crates/storage", version = "0.1.0" }shop-api = { path = "crates/api", version = "0.1.0" }
[workspace.lints.rust]unsafe_code = "forbid"missing_docs = "warn"
[workspace.lints.clippy]dbg_macro = "warn"# Keep short struct literals and calls on one line.use_small_heuristics = "Max"[workspace.package] carries the version, edition, minimum Rust version and license; every member inherits them. [workspace.dependencies] carries the version of serde_json and the paths of the three library crates, so a member writes shop-core = { workspace = true }. [workspace.lints] sets the lint levels; each member opts in with [lints] workspace = true. The package names start with shop- on purpose. A package called core can be confused with the standard library’s crate of that name, and Cargo warns when you create one:
$ cargo new --lib core
Creating library `core` package
warning: package name `core` may be confused with the package with that name in Rust's standard library
It is recommended to use a different name to avoid problems.
note: the directory name is used as the package name
help: to override the package name, pass `--name <pkgname>`
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
The directory is still crates/core. Only the package name matters to Cargo and to use paths, which write shop_core.
Step 1: shop-core, the domain
Section titled “Step 1: shop-core, the domain”The bottom crate has the shop’s vocabulary: Money (whole cents), Sku (a text checked against the SKU rules when it is built), Product, an Error enum and the Catalog trait. Its manifest has no [dependencies] section at all. The file lib.rs keeps the modules private and re-exports the names callers need, as Modules describes.
[package]name = "shop-core"description = "The shop's domain types and the Catalog trait"version.workspace = trueedition.workspace = truerust-version.workspace = truelicense.workspace = true
[lints]workspace = true//! The shop's domain: prices, SKUs, products, and the [`Catalog`] trait that//! storage crates implement. This crate has no dependencies.
mod catalog;mod error;mod money;mod product;
pub use catalog::{Catalog, order_total};pub use error::Error;pub use money::Money;pub use product::{Product, Sku};use crate::{Error, Money, Product, Sku};
/// Where products are kept. `shop-core` only says what a catalog can do;/// the `shop-storage` crate holds the implementations.pub trait Catalog: Send + Sync { /// The product with this SKU, if there is one. fn get(&self, sku: &Sku) -> Result<Option<Product>, Error>;
/// Adds a product, or replaces the one with the same SKU. fn put(&self, product: Product) -> Result<(), Error>;
/// Every product, in SKU order. fn list(&self) -> Result<Vec<Product>, Error>;}
/// The price of an order: each `(sku, quantity)` line at its catalog price.pub fn order_total(catalog: &dyn Catalog, lines: &[(Sku, u32)]) -> Result<Money, Error> { let mut total = Money::default(); for (sku, quantity) in lines { let product = catalog.get(sku)?.ok_or_else(|| Error::NotFound(sku.clone()))?; let line = product.price.checked_mul(*quantity).ok_or(Error::Overflow)?; total = total.checked_add(line).ok_or(Error::Overflow)?; } Ok(total)}
#[cfg(test)]mod tests { use super::*;
/// A catalog made of a fixed list: enough to test `order_total` with no storage crate. struct Fixed(Vec<Product>);
impl Catalog for Fixed { fn get(&self, sku: &Sku) -> Result<Option<Product>, Error> { Ok(self.0.iter().find(|p| &p.sku == sku).cloned()) } fn put(&self, _: Product) -> Result<(), Error> { Err(Error::Store("read-only".into())) } fn list(&self) -> Result<Vec<Product>, Error> { Ok(self.0.clone()) } }
fn product(sku: &str, cents: u64) -> Product { Product { sku: sku.parse().unwrap(), name: sku.to_lowercase(), price: Money::from_cents(cents), } }
#[test] fn totals_lines() { let catalog = Fixed(vec![product("TEA", 450), product("MUG", 1200)]); let lines = [("TEA".parse().unwrap(), 2), ("MUG".parse().unwrap(), 1)]; assert_eq!(order_total(&catalog, &lines), Ok(Money::from_cents(2100))); }
#[test] fn unknown_sku_is_not_found() { let catalog = Fixed(vec![]); let sku: Sku = "TEA".parse().unwrap(); assert_eq!(order_total(&catalog, &[(sku.clone(), 1)]), Err(Error::NotFound(sku))); }
#[test] fn huge_totals_overflow() { let catalog = Fixed(vec![product("GEM", u64::MAX)]); let lines = [("GEM".parse().unwrap(), 2)]; assert_eq!(order_total(&catalog, &lines), Err(Error::Overflow)); }}use std::fmt;use std::str::FromStr;
use crate::{Error, Money};
/// A stock-keeping unit: 3 to 12 characters of `A-Z`, `0-9` or `-`.////// ```/// use shop_core::Sku;////// let sku: Sku = "TEA-01".parse()?;/// assert_eq!(sku.as_str(), "TEA-01");/// assert!("tea".parse::<Sku>().is_err());/// # Ok::<(), shop_core::Error>(())/// ```#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]pub struct Sku(String);
impl Sku { /// The SKU as text. pub fn as_str(&self) -> &str { &self.0 }}
impl FromStr for Sku { type Err = Error;
fn from_str(s: &str) -> Result<Sku, Error> { let ok_char = |c: char| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-'; if (3..=12).contains(&s.len()) && s.chars().all(ok_char) { Ok(Sku(s.to_string())) } else { Err(Error::InvalidSku(s.to_string())) } }}
impl fmt::Display for Sku { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) }}
/// Something the shop sells.#[derive(Debug, Clone, PartialEq, Eq)]pub struct Product { /// Its identifier. pub sku: Sku, /// What customers see. pub name: String, /// The price of one. pub price: Money,}
impl fmt::Display for Product { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} {} {}", self.sku, self.price, self.name) }}
#[cfg(test)]mod tests { use super::*;
#[test] fn sku_rules() { for good in ["TEA", "TEA-01", "ABCDEFGHIJKL"] { assert!(good.parse::<Sku>().is_ok(), "{good}"); } for bad in ["", "TE", "tea", "TEA 01", "ABCDEFGHIJKLM"] { assert_eq!(bad.parse::<Sku>(), Err(Error::InvalidSku(bad.to_string())), "{bad:?}"); } }}use std::fmt;
use crate::Sku;
/// Everything that can go wrong in the shop.#[derive(Debug, Clone, PartialEq, Eq)]#[non_exhaustive]pub enum Error { /// A SKU that is not 3 to 12 characters of `A-Z`, `0-9` or `-`. InvalidSku(String), /// A price that is not a whole number of cents. InvalidPrice(String), /// A quantity that is not a whole number above zero. InvalidQuantity(String), /// No product has this SKU. NotFound(Sku), /// A total that does not fit in the price type. Overflow, /// The storage behind a [`Catalog`](crate::Catalog) failed. Store(String),}
impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Error::InvalidSku(s) => write!(f, "invalid SKU {s:?}: use 3 to 12 of A-Z, 0-9, -"), Error::InvalidPrice(s) => write!(f, "invalid price {s:?}: use whole cents"), Error::InvalidQuantity(s) => write!(f, "invalid quantity {s:?}: use a number above 0"), Error::NotFound(sku) => write!(f, "no product {sku}"), Error::Overflow => write!(f, "total is too large"), Error::Store(why) => write!(f, "storage failed: {why}"), } }}
impl std::error::Error for Error {}use std::fmt;use std::str::FromStr;
use crate::Error;
/// An amount of money in whole cents.#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]pub struct Money(u64);
impl Money { /// Money from a number of cents. pub const fn from_cents(cents: u64) -> Money { Money(cents) }
/// The amount in cents. pub const fn cents(self) -> u64 { self.0 }
/// `self + other`, or `None` if it does not fit. pub fn checked_add(self, other: Money) -> Option<Money> { self.0.checked_add(other.0).map(Money) }
/// `self * quantity`, or `None` if it does not fit. pub fn checked_mul(self, quantity: u32) -> Option<Money> { self.0.checked_mul(u64::from(quantity)).map(Money) }}
impl fmt::Display for Money { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}.{:02}", self.0 / 100, self.0 % 100) }}
impl FromStr for Money { type Err = Error;
/// Parses whole cents: `"1250"` is 12.50. fn from_str(s: &str) -> Result<Money, Error> { s.parse::<u64>().map(Money).map_err(|_| Error::InvalidPrice(s.to_string())) }}
#[cfg(test)]mod tests { use super::*;
#[test] fn displays_dollars_and_cents() { assert_eq!(Money::from_cents(1250).to_string(), "12.50"); assert_eq!(Money::from_cents(5).to_string(), "0.05"); }
#[test] fn arithmetic_reports_overflow() { let big = Money::from_cents(u64::MAX); assert_eq!(big.checked_add(Money::from_cents(1)), None); assert_eq!(big.checked_mul(2), None); assert_eq!(Money::from_cents(250).checked_mul(3), Some(Money::from_cents(750))); }
#[test] fn parses_cents_only() { assert_eq!("1250".parse::<Money>(), Ok(Money::from_cents(1250))); assert!("12.50".parse::<Money>().is_err()); }}Two choices are worth noticing. Catalog is a trait with get, put and list, and nothing in shop-core says where products are kept (Traits). And order_total takes &dyn Catalog, so it runs against any implementation. Its tests build a throwaway Fixed catalog inside the test module, so shop-core is tested without any storage crate. The error type is a plain enum marked #[non_exhaustive], which Project structure explains, and Errors in applications covers how a program on top handles it.
$ cargo test -p shop-core # timings replaced by X.XXs
test catalog::tests::huge_totals_overflow ... ok
test money::tests::displays_dollars_and_cents ... ok
test money::tests::parses_cents_only ... ok
test product::tests::sku_rules ... ok
test catalog::tests::unknown_sku_is_not_found ... ok
test catalog::tests::totals_lines ... ok
test money::tests::arithmetic_reports_overflow ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
Doc-tests shop_core
test crates/core/src/product.rs - product::Sku (line 8) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
The last block is a doc test: the example in the comment on Sku is compiled and run.
Step 2: shop-storage, one backend always and one behind a feature
Section titled “Step 2: shop-storage, one backend always and one behind a feature”The storage crate implements Catalog twice. MemoryCatalog is always built. FileCatalog stores one JSON object per line in a file and needs serde_json, so it is a feature. The dependency is marked optional = true, and file = ["dep:serde_json"] ties the feature to it. #[cfg(feature = "file")] hides the module and its re-export when the feature is off (Features).
[package]name = "shop-storage"description = "Catalog implementations: in memory, and (feature `file`) in a file"version.workspace = trueedition.workspace = truerust-version.workspace = truelicense.workspace = true
[features]# Off by default: `FileCatalog` needs serde_json.file = ["dep:serde_json"]
[dependencies]shop-core = { workspace = true }serde_json = { workspace = true, optional = true }
[lints]workspace = true//! Implementations of [`shop_core::Catalog`].//!//! [`MemoryCatalog`] is always there. With the `file` feature on,//! `FileCatalog` keeps products in a text file, one JSON object per line.
mod memory;pub use memory::MemoryCatalog;
#[cfg(feature = "file")]mod file;#[cfg(feature = "file")]pub use file::FileCatalog;use std::collections::BTreeMap;use std::sync::{Mutex, MutexGuard};
use shop_core::{Catalog, Error, Product, Sku};
/// A catalog that lives as long as the program.#[derive(Debug, Default)]pub struct MemoryCatalog { items: Mutex<BTreeMap<Sku, Product>>,}
impl MemoryCatalog { /// An empty catalog. pub fn new() -> MemoryCatalog { MemoryCatalog::default() }
fn items(&self) -> Result<MutexGuard<'_, BTreeMap<Sku, Product>>, Error> { self.items.lock().map_err(|_| Error::Store("lock poisoned".into())) }}
impl Catalog for MemoryCatalog { fn get(&self, sku: &Sku) -> Result<Option<Product>, Error> { Ok(self.items()?.get(sku).cloned()) }
fn put(&self, product: Product) -> Result<(), Error> { self.items()?.insert(product.sku.clone(), product); Ok(()) }
fn list(&self) -> Result<Vec<Product>, Error> { Ok(self.items()?.values().cloned().collect()) }}use std::collections::BTreeMap;use std::fs;use std::io::ErrorKind;use std::path::PathBuf;
use serde_json::{Value, json};use shop_core::{Catalog, Error, Money, Product, Sku};
/// A catalog kept in a file, one JSON object per line. Every call reads or/// rewrites the whole file, which is fine for a small shop and no more.#[derive(Debug)]pub struct FileCatalog { path: PathBuf,}
impl FileCatalog { /// A catalog stored at `path`. The file is created by the first `put`. pub fn new(path: impl Into<PathBuf>) -> FileCatalog { FileCatalog { path: path.into() } }
fn load(&self) -> Result<BTreeMap<Sku, Product>, Error> { let text = match fs::read_to_string(&self.path) { Ok(text) => text, Err(e) if e.kind() == ErrorKind::NotFound => return Ok(BTreeMap::new()), Err(e) => return Err(Error::Store(e.to_string())), }; let mut items = BTreeMap::new(); for (n, line) in text.lines().enumerate() { let product = parse_line(line) .ok_or_else(|| Error::Store(format!("line {} is not a product", n + 1)))?; items.insert(product.sku.clone(), product); } Ok(items) }}
fn parse_line(line: &str) -> Option<Product> { let value: Value = serde_json::from_str(line).ok()?; Some(Product { sku: value["sku"].as_str()?.parse().ok()?, name: value["name"].as_str()?.to_string(), price: Money::from_cents(value["cents"].as_u64()?), })}
impl Catalog for FileCatalog { fn get(&self, sku: &Sku) -> Result<Option<Product>, Error> { Ok(self.load()?.remove(sku)) }
fn put(&self, product: Product) -> Result<(), Error> { let mut items = self.load()?; items.insert(product.sku.clone(), product); let mut text = String::new(); for p in items.values() { let line = json!({ "sku": p.sku.as_str(), "name": p.name, "cents": p.price.cents() }); text.push_str(&line.to_string()); text.push('\n'); } fs::write(&self.path, text).map_err(|e| Error::Store(e.to_string())) }
fn list(&self) -> Result<Vec<Product>, Error> { Ok(self.load()?.into_values().collect()) }}Both must behave the same, so tests/contract.rs holds one function, contract(&dyn Catalog), and runs it on each backend. The second test exists only when the feature is on.
//! One set of checks that every `Catalog` must pass, run against each backend.
use shop_core::{Catalog, Money, Product};use shop_storage::MemoryCatalog;
fn product(sku: &str, name: &str, cents: u64) -> Product { Product { sku: sku.parse().unwrap(), name: name.to_string(), price: Money::from_cents(cents) }}
fn contract(catalog: &dyn Catalog) { assert!(catalog.list().unwrap().is_empty()); assert_eq!(catalog.get(&"TEA".parse().unwrap()).unwrap(), None);
catalog.put(product("TEA", "Green tea, 100 g", 450)).unwrap(); catalog.put(product("MUG", "Enamel mug", 1200)).unwrap(); catalog.put(product("TEA", "Green tea, 200 g", 800)).unwrap();
let all = catalog.list().unwrap(); let skus: Vec<&str> = all.iter().map(|p| p.sku.as_str()).collect(); assert_eq!(skus, ["MUG", "TEA"], "listed in SKU order, TEA stored once"); assert_eq!( catalog.get(&"TEA".parse().unwrap()).unwrap(), Some(product("TEA", "Green tea, 200 g", 800)) );}
#[test]fn memory_catalog_keeps_the_contract() { contract(&MemoryCatalog::new());}
#[cfg(feature = "file")]#[test]fn file_catalog_keeps_the_contract() { use shop_storage::FileCatalog;
let path = std::env::temp_dir().join(format!("shop-contract-{}.jsonl", std::process::id())); let _ = std::fs::remove_file(&path); contract(&FileCatalog::new(&path)); // A second catalog on the same file sees what the first one wrote. assert_eq!(FileCatalog::new(&path).list().unwrap().len(), 2); std::fs::remove_file(&path).unwrap();}Run the crate’s tests twice:
$ cargo test -p shop-storage # timings replaced by X.XXs test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs test memory_catalog_keeps_the_contract ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs $ cargo test -p shop-storage --features file # timings replaced by X.XXs test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs test memory_catalog_keeps_the_contract ... ok test file_catalog_keeps_the_contract ... ok test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
The first run has one contract test and the second has two. With the feature off, the item is not just unused but absent, and the compiler says why when you ask for it. This run builds a file crates/api/examples/gated.rs in shop-api (the next step’s crate) that imports FileCatalog without the feature:
//! Uses the file-backed catalog without asking for the `file` feature.use shop_storage::FileCatalog;
fn main() { let _catalog = FileCatalog::new("shop.jsonl");}error[E0432]: unresolved import `shop_storage::FileCatalog` --> crates/api/examples/gated.rs:2:5 | 2 | use shop_storage::FileCatalog; | ^^^^^^^^^^^^^^----------- | | | no `FileCatalog` in the root | note: found an item that was configured out --> crates/storage/src/lib.rs:12:15 | 11 | #[cfg(feature = "file")] | ---------------- the item is gated behind the `file` feature 12 | pub use file::FileCatalog; | ^^^^^^^^^^^
That message names the feature, which a plain “not found” would not.
Step 3: shop-api, requests in and statuses out
Section titled “Step 3: shop-api, requests in and statuses out”The API crate turns a text request into a Response with a number and a body. It holds a Box<dyn Catalog> rather than a generic parameter, because the program at the top decides at run time which backend to give it (Generics or dyn?). There is no network code: a real server would call Api::handle for each request it reads, and the tests call it directly. A match on a slice pattern (Patterns) picks the request, and ? passes each Error up to handle, which turns it into a status.
//! The shop's API: one text request in, a status and a body out. There is no//! network here; a server would call [`Api::handle`] for each request it reads.//!//! | Request | Meaning |//! | --- | --- |//! | `PUT <sku> <cents> <name...>` | add or replace a product |//! | `GET <sku>` | show a product |//! | `LIST` | show every product |//! | `TOTAL <sku>:<quantity> ...` | price an order |
use shop_core::{Catalog, Error, Money, Product, Sku, order_total};
/// What [`Api::handle`] returns. The status numbers follow HTTP's meanings.#[derive(Debug, Clone, PartialEq, Eq)]pub struct Response { /// 200 ok, 201 created, 400 bad request, 404 not found, 500 server error. pub status: u16, /// Text for the caller. pub body: String,}
/// The handler. It owns a catalog behind the [`Catalog`] trait and does not/// know which kind.pub struct Api { catalog: Box<dyn Catalog>,}
impl Api { /// An API on top of `catalog`. pub fn new(catalog: Box<dyn Catalog>) -> Api { Api { catalog } }
/// Runs one request line. pub fn handle(&self, request: &str) -> Response { match self.run(request) { Ok(response) => response, Err(e) => Response { status: status_of(&e), body: e.to_string() }, } }
fn run(&self, request: &str) -> Result<Response, Error> { // Up to four pieces: the name of a PUT is everything after the price. let parts: Vec<&str> = request.trim().splitn(4, ' ').collect(); match parts.as_slice() { ["PUT", sku, cents, name] => { let sku: Sku = sku.parse()?; let price: Money = cents.parse()?; self.catalog.put(Product { sku, name: name.trim().to_string(), price })?; Ok(Response { status: 201, body: "stored".into() }) } ["GET", sku] => { let sku: Sku = sku.parse()?; let product = self.catalog.get(&sku)?.ok_or(Error::NotFound(sku))?; Ok(Response { status: 200, body: product.to_string() }) } ["LIST"] => { let lines: Vec<String> = self.catalog.list()?.iter().map(Product::to_string).collect(); Ok(Response { status: 200, body: lines.join("\n") }) } ["TOTAL", _, ..] => { let mut lines = Vec::new(); for word in request.split_whitespace().skip(1) { let (sku, quantity) = word.split_once(':').unwrap_or((word, "1")); let quantity: u32 = quantity .parse() .ok() .filter(|q| *q > 0) .ok_or_else(|| Error::InvalidQuantity(quantity.to_string()))?; lines.push((sku.parse()?, quantity)); } let total = order_total(&*self.catalog, &lines)?; Ok(Response { status: 200, body: total.to_string() }) } _ => Ok(Response { status: 400, body: format!("unknown request {request:?}") }), } }}
fn status_of(error: &Error) -> u16 { match error { Error::NotFound(_) => 404, Error::Store(_) => 500, _ => 400, }}
#[cfg(test)]mod tests { use shop_storage::MemoryCatalog;
use super::*;
fn api() -> Api { Api::new(Box::new(MemoryCatalog::new())) }
fn reply(api: &Api, request: &str) -> (u16, String) { let r = api.handle(request); (r.status, r.body) }
#[test] fn put_then_get() { let api = api(); assert_eq!(reply(&api, "PUT TEA 450 Green tea, 100 g"), (201, "stored".into())); assert_eq!(reply(&api, "GET TEA"), (200, "TEA 4.50 Green tea, 100 g".into())); }
#[test] fn list_is_in_sku_order() { let api = api(); api.handle("PUT TEA 450 Tea"); api.handle("PUT MUG 1200 Mug"); assert_eq!(reply(&api, "LIST").1, "MUG 12.00 Mug\nTEA 4.50 Tea"); }
#[test] fn total_prices_an_order() { let api = api(); api.handle("PUT TEA 450 Tea"); api.handle("PUT MUG 1200 Mug"); assert_eq!(reply(&api, "TOTAL TEA:2 MUG"), (200, "21.00".into())); }
#[test] fn errors_become_statuses() { let api = api(); assert_eq!(reply(&api, "GET TEA").0, 404); assert_eq!(reply(&api, "GET tea").0, 400); assert_eq!(reply(&api, "PUT TEA 4.50 Tea").0, 400); assert_eq!(reply(&api, "TOTAL TEA:0").0, 400); assert_eq!(reply(&api, "DELETE TEA").0, 400); }}[package]name = "shop-api"description = "The shop's request handler: text in, status and body out"version.workspace = trueedition.workspace = truerust-version.workspace = truelicense.workspace = true
[dependencies]shop-core = { workspace = true }
[dev-dependencies]shop-storage = { workspace = true }
[lints]workspace = trueshop-storage appears only under [dev-dependencies]: the tests use MemoryCatalog, but the library does not depend on any backend. The graph below shows that.
$ cargo test -p shop-api # timings replaced by X.XXs
test tests::errors_become_statuses ... ok
test tests::list_is_in_sku_order ... ok
test tests::put_then_get ... ok
test tests::total_prices_an_order ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
Step 4: shop-cli, choosing the backend
Section titled “Step 4: shop-cli, choosing the backend”The program is the one place that knows the concrete types. Its own feature, file = ["shop-storage/file"], passes the switch down to the storage crate. With the feature on, --db FILE opens a FileCatalog. With it off, the same flag prints a message that says which feature is missing. Without --db the catalog lives in memory and the requests come from the command line or, when none are given, from standard input. The exit status is 0 when every request succeeded, 1 when one returned a status of 400 or more, and 2 when --db cannot be used (no file name, or the feature is off).
[package]name = "shop-cli"description = "The shop on the command line"version.workspace = trueedition.workspace = truerust-version.workspace = truelicense.workspace = true
[features]# Off by default: `--db <file>` needs the file-backed catalog.file = ["shop-storage/file"]
[dependencies]shop-core = { workspace = true }shop-storage = { workspace = true }shop-api = { workspace = true }
[[bin]]name = "shop"path = "src/main.rs"
[lints]workspace = true//! `shop [--db FILE] [REQUEST...]`: runs the request given on the command line,//! or one request per line from standard input.
use std::io::{self, BufRead};use std::process::ExitCode;
use shop_api::Api;use shop_core::Catalog;use shop_storage::MemoryCatalog;
#[cfg(feature = "file")]fn open(path: &str) -> Result<Box<dyn Catalog>, String> { Ok(Box::new(shop_storage::FileCatalog::new(path)))}
#[cfg(not(feature = "file"))]fn open(_path: &str) -> Result<Box<dyn Catalog>, String> { Err("--db needs the `file` feature: cargo run -p shop-cli --features file".to_string())}
fn main() -> ExitCode { let mut args: Vec<String> = std::env::args().skip(1).collect(); let catalog: Box<dyn Catalog> = if args.first().map(String::as_str) == Some("--db") { if args.len() < 2 { eprintln!("shop: --db needs a file name"); return ExitCode::from(2); } let path = args[1].clone(); args.drain(..2); match open(&path) { Ok(catalog) => catalog, Err(why) => { eprintln!("shop: {why}"); return ExitCode::from(2); } } } else { Box::new(MemoryCatalog::new()) }; let api = Api::new(catalog);
let requests: Vec<String> = if args.is_empty() { io::stdin().lock().lines().map_while(Result::ok).filter(|l| !l.trim().is_empty()).collect() } else { vec![args.join(" ")] };
let mut failed = false; for request in requests { let response = api.handle(&request); println!("{} {}", response.status, response.body); failed |= response.status >= 400; } if failed { ExitCode::from(1) } else { ExitCode::SUCCESS }}With the feature off, a script on standard input works against memory, and a request for a product that is not there prints 404 ... and exits with 1. The --db flag is refused:
$ printf 'PUT TEA 450 Green tea, 100 g\nPUT MUG 1200 Enamel mug\nLIST\nTOTAL TEA:2 MUG\n' | cargo run -q -p shop-cli 201 stored 201 stored 200 MUG 12.00 Enamel mug TEA 4.50 Green tea, 100 g 200 21.00 $ cargo run -q -p shop-cli -- GET TEA # a new run, so the memory catalog is empty; then print the exit status 404 no product TEA exit status 1 $ cargo run -q -p shop-cli -- --db shop.jsonl LIST # then print the exit status shop: --db needs the `file` feature: cargo run -p shop-cli --features file exit status 2
With the feature on, the data survives between runs because it is in a file, and the file is plain lines you can read:
$ cargo run -q -p shop-cli --features file -- --db shop.jsonl PUT MUG 1200 Enamel mug 201 stored $ cargo run -q -p shop-cli --features file -- --db shop.jsonl PUT TEA 450 Green tea, 100 g 201 stored $ cargo run -q -p shop-cli --features file -- --db shop.jsonl LIST 200 MUG 12.00 Enamel mug TEA 4.50 Green tea, 100 g $ cat shop.jsonl {"cents":1200,"name":"Enamel mug","sku":"MUG"} {"cents":450,"name":"Green tea, 100 g","sku":"TEA"}
The integration tests in tests/script.rs run the real program with std::process::Command. Cargo sets CARGO_BIN_EXE_shop to the path of the binary while it builds the test. One test exists only without the feature (the refusal) and one only with it (the file survives a second run), so each build of the workspace tests the behaviour it has. Testing covers the test basics used here.
//! Runs the real `shop` program, the way a user would.
use std::io::Write;use std::process::{Command, Output, Stdio};
fn shop(args: &[&str], stdin: &str) -> Output { let mut child = Command::new(env!("CARGO_BIN_EXE_shop")) .args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap(); child.wait_with_output().unwrap()}
#[test]fn a_script_runs_against_memory() { let out = shop(&[], "PUT TEA 450 Tea\nTOTAL TEA:3\n"); assert_eq!(String::from_utf8_lossy(&out.stdout), "201 stored\n200 13.50\n"); assert!(out.status.success());}
#[test]fn a_failed_request_sets_the_exit_code() { let out = shop(&["GET", "TEA"], ""); assert_eq!(String::from_utf8_lossy(&out.stdout), "404 no product TEA\n"); assert_eq!(out.status.code(), Some(1));}
#[cfg(not(feature = "file"))]#[test]fn db_is_refused_without_the_feature() { let out = shop(&["--db", "x.jsonl", "LIST"], ""); assert_eq!(out.status.code(), Some(2)); assert!(String::from_utf8_lossy(&out.stderr).contains("`file` feature"));}
#[cfg(feature = "file")]#[test]fn products_survive_between_runs() { let db = std::env::temp_dir().join(format!("shop-cli-{}.jsonl", std::process::id())); let db_arg = db.to_str().unwrap(); let _ = std::fs::remove_file(&db); shop(&["--db", db_arg, "PUT", "MUG", "1200", "Enamel", "mug"], ""); let out = shop(&["--db", db_arg, "GET", "MUG"], ""); assert_eq!(String::from_utf8_lossy(&out.stdout), "200 MUG 12.00 Enamel mug\n"); std::fs::remove_file(&db).unwrap();}The dependency graph
Section titled “The dependency graph”cargo tree prints the graph Cargo built. Read it from a crate down to what it needs. First with every feature off, then with all on, then inverted for shop-core to see who needs it (cargo tree).
$ cargo tree --workspace shop-api v0.1.0 (/home/you/shop/crates/api) └── shop-core v0.1.0 (/home/you/shop/crates/core) [dev-dependencies] └── shop-storage v0.1.0 (/home/you/shop/crates/storage) └── shop-core v0.1.0 (/home/you/shop/crates/core) shop-cli v0.1.0 (/home/you/shop/crates/cli) ├── shop-api v0.1.0 (/home/you/shop/crates/api) (*) ├── shop-core v0.1.0 (/home/you/shop/crates/core) └── shop-storage v0.1.0 (/home/you/shop/crates/storage) (*) shop-core v0.1.0 (/home/you/shop/crates/core) shop-storage v0.1.0 (/home/you/shop/crates/storage) (*) $ cargo tree --workspace --all-features shop-api v0.1.0 (/home/you/shop/crates/api) └── shop-core v0.1.0 (/home/you/shop/crates/core) [dev-dependencies] └── shop-storage v0.1.0 (/home/you/shop/crates/storage) ├── serde_json v1.0.154 │ ├── itoa v1.0.18 │ ├── memchr v2.8.3 │ ├── serde_core v1.0.229 │ └── zmij v1.0.23 └── shop-core v0.1.0 (/home/you/shop/crates/core) shop-cli v0.1.0 (/home/you/shop/crates/cli) ├── shop-api v0.1.0 (/home/you/shop/crates/api) (*) ├── shop-core v0.1.0 (/home/you/shop/crates/core) └── shop-storage v0.1.0 (/home/you/shop/crates/storage) (*) shop-core v0.1.0 (/home/you/shop/crates/core) shop-storage v0.1.0 (/home/you/shop/crates/storage) (*) $ cargo tree --workspace -i shop-core shop-core v0.1.0 (/home/you/shop/crates/core) ├── shop-api v0.1.0 (/home/you/shop/crates/api) │ └── shop-cli v0.1.0 (/home/you/shop/crates/cli) ├── shop-cli v0.1.0 (/home/you/shop/crates/cli) └── shop-storage v0.1.0 (/home/you/shop/crates/storage) └── shop-cli v0.1.0 (/home/you/shop/crates/cli) [dev-dependencies] └── shop-api v0.1.0 (/home/you/shop/crates/api) (*)
From these three listings:
shop-corehas nothing under it, and three crates depend on it directly:shop-api,shop-storageandshop-cli.shop-apidoes not depend onshop-storageexcept as a dev-dependency, whichcargo treeprints in its own[dev-dependencies]group.shop-cliis the one crate that depends on all three libraries.serde_jsonand the four crates under it appear only with the feature on, and only belowshop-storage. A build that never asks forfiledoes not have them in its graph.- A mark
(*)means the subtree was already printed above.
One set of lints and one lock file
Section titled “One set of lints and one lock file”All four crates take their lint levels from the root: unsafe_code is forbidden, missing_docs warns, and Clippy’s dbg_macro warns. Clippy over every target and feature printed nothing, which includes no missing-docs warning:
$ cargo clippy --workspace --all-targets --all-features
To see the shared rule at work, here is an unsafe block in crates/api/examples/danger.rs, an example of shop-api, a crate that never mentions unsafe_code in its own manifest:
//! An `unsafe` block in a crate that inherits the workspace lints.fn main() { let n = 7; let p = &n as *const i32; println!("{}", unsafe { *p });}error: usage of an `unsafe` block --> crates/api/examples/danger.rs:5:20 | 5 | println!("{}", unsafe { *p }); | ^^^^^^^^^^^^^ | = note: requested on the command line with `-F unsafe-code`
The note at the end says the compiler was given the level on its command line: Cargo passes the workspace’s setting down as a flag.
Last, everything together. The workspace has 16 passing tests with the feature off and 17 with all features on, because the file contract test exists only in the second build. cargo build --locked exits with an error if Cargo would have to change Cargo.lock (build options), so it checks that the committed file still fits the manifests. cargo fmt --check passes with the settings in rustfmt.toml. The lock file has 15 packages, more than the nine in the largest graph above; Project structure explains why a lock file can list packages that a build never compiles.
$ cargo test --workspace # then count the passing tests 16 $ cargo test --workspace --all-features # then count the passing tests 17 $ cargo build --workspace --locked $ cargo fmt --check $ grep -c "^name = " Cargo.lock # packages in the lock file 15
Try it yourself
Section titled “Try it yourself”The first session below ran tasks 1 and 2. The second ran the first step of task 3.
- Ask a crate for a feature it does not have:
cargo build -p shop-core --features file. Cargo refuses and names the packages that do definefile. cargo test -p shop-cli --features file. The program’s tests still number three, but the refusal test is replaced by the one that writes a file and reads it in a second process.- Add
fn remove(&self, sku: &Sku) -> Result<bool, Error>;to theCatalogtrait and runcargo build --workspace --all-features. The build stopped with E0046 twice, once for each implementation inshop-storage; nothing is reported forshop-core, becausecargo buildskips its test code. Implementremovein all three implementations:MemoryCatalog,FileCatalogand theFixedcatalog in the tests ofshop-core, wherecargo testreports the same error. Then add aDELETE <sku>request toshop-api. Its testerrors_become_statusesexpectsDELETE TEAto answer 400, so change that line.
$ cargo build -p shop-core --features file error: the package 'shop-core' does not contain this feature: file help: packages with the missing feature: shop-storage, shop-cli $ cargo test -p shop-cli --features file # timings replaced by X.XXs test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs test a_failed_request_sets_the_exit_code ... ok test a_script_runs_against_memory ... ok test products_survive_between_runs ... ok test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
error[E0046]: not all trait items implemented, missing: `remove` --> crates/storage/src/memory.rs:23:1 | 23 | impl Catalog for MemoryCatalog { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `remove` in implementation | = help: implement the missing item: `fn remove(&self, _: &Sku) -> Result<bool, shop_core::Error> { todo!() }` error[E0046]: not all trait items implemented, missing: `remove` --> crates/storage/src/file.rs:47:1 | 47 | impl Catalog for FileCatalog { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `remove` in implementation | = help: implement the missing item: `fn remove(&self, _: &Sku) -> Result<bool, shop_core::Error> { todo!() }`
What to try next
Section titled “What to try next”These are suggestions; none is in the project above and none was run.
- Make
FileCatalogwrite to a temporary file in the same directory and rename it over the old one, so a crash is far less likely to leave half a file (File I/O). - Add a third backend.
contractcannot be called from another crate as it stands, because it sits intests/contract.rs, a test crate that nothing imports. Move it into a library first: apub fnin a smallshop-testkitcrate that depends onshop-core, called from the tests of each backend. - Put a real network front on
shop-api. The axum guide builds one, and the handler there can callApi::handle. - Replace the hand-written
Errorwiththiserror(Errors in applications) and compare how much code goes. - The workspace says
rust-version = "1.85". Install that toolchain and build with it, as Project structure shows.
Read more
Section titled “Read more”- The Cargo Book: Workspaces, Features,
cargo tree - In this course: Project structure, Traits, Trait objects, Modules, Errors in applications
Versions on this page: Rust 1.99.1 and serde_json as pinned in Cargo.lock.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.