Skip to content
Learn Rust
Theme

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.

Project layout
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 root Cargo.toml has no [package]. It lists the members, and it holds the three things the members share, so each is written once.

Rust
[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"

[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:

terminal
$ 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.

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.

Rust
[package]
name = "shop-core"
description = "The shop's domain types and the Catalog trait"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true

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.

terminal
$ 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).

Rust
[package]
name = "shop-storage"
description = "Catalog implementations: in memory, and (feature `file`) in a file"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.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

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.

Rustcrates/storage/tests/contract.rs
//! 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:

terminal
$ 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:

Rustgated.rs
//! Uses the file-backed catalog without asking for the `file` feature.
use shop_storage::FileCatalog;
fn main() {
let _catalog = FileCatalog::new("shop.jsonl");
}
$ cargo build -p shop-api --example gated
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.

Rust
//! 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);
}
}

shop-storage appears only under [dev-dependencies]: the tests use MemoryCatalog, but the library does not depend on any backend. The graph below shows that.

terminal
$ 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

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).

Rust
[package]
name = "shop-cli"
description = "The shop on the command line"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.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

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:

terminal
$ 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:

terminal
$ 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.

Rustcrates/cli/tests/script.rs
//! 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();
}

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).

terminal
$ 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-core has nothing under it, and three crates depend on it directly: shop-api, shop-storage and shop-cli.
  • shop-api does not depend on shop-storage except as a dev-dependency, which cargo tree prints in its own [dev-dependencies] group. shop-cli is the one crate that depends on all three libraries.
  • serde_json and the four crates under it appear only with the feature on, and only below shop-storage. A build that never asks for file does not have them in its graph.
  • A mark (*) means the subtree was already printed above.

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:

terminal
$ 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:

Rustdanger.rs
//! 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 });
}
$ cargo build -p shop-api --example danger
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.

terminal
$ 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

The first session below ran tasks 1 and 2. The second ran the first step of task 3.

  1. 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 define file.
  2. 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.
  3. Add fn remove(&self, sku: &Sku) -> Result<bool, Error>; to the Catalog trait and run cargo build --workspace --all-features. The build stopped with E0046 twice, once for each implementation in shop-storage; nothing is reported for shop-core, because cargo build skips its test code. Implement remove in all three implementations: MemoryCatalog, FileCatalog and the Fixed catalog in the tests of shop-core, where cargo test reports the same error. Then add a DELETE <sku> request to shop-api. Its test errors_become_statuses expects DELETE TEA to answer 400, so change that line.
terminal
$ 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
$ cargo build --workspace --all-features
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!() }`

These are suggestions; none is in the project above and none was run.

  • Make FileCatalog write 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. contract cannot be called from another crate as it stands, because it sits in tests/contract.rs, a test crate that nothing imports. Move it into a library first: a pub fn in a small shop-testkit crate that depends on shop-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 call Api::handle.
  • Replace the hand-written Error with thiserror (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.

Versions on this page: Rust 1.99.1 and serde_json as pinned in Cargo.lock.