Rust's compiler already rules out whole classes of bugs before a program runs (see Ownership and Borrowing and References). It cannot know whether the program does the right thing: a CRC function can compile without a single warning and still compute the wrong value. That is what tests are for. What makes Rust pleasant here is that the test runner, the documentation generator, a linter and a formatter all come with the standard toolchain, with the same commands in every project. The general ideas behind unit tests and the test pyramid are on Test Levels and Test Types; this page shows how Rust does them.

The examples follow one story. In this example, a developer on Kestrel's platform team writes a small Rust library, p1crc, as a prototype of the CRC check from BUG-1060, to compare it with the dongle manufacturer's C fix. The dongle firmware itself stays in C; the prototype is a way to pin the expected behavior down in tests.

Unit tests

A test in Rust is an ordinary function without parameters, marked with the attribute #[test]. The test passes when the function returns normally and fails when it panics. The assertion macros panic with a helpful message when a condition does not hold:

Macro Fails when
assert!(condition) the condition is false
assert_eq!(left, right) the two values differ; prints both with their Debug form
assert_ne!(left, right) the two values are equal
debug_assert! and friends the same, but only checked in debug builds (not in release builds)

All of them take an optional message with the same placeholders as println!, which is worth adding whenever the values alone do not say what went wrong. The assertion macros also work in normal code: an assert! that fails in production is a panic, so use them there for "this can never happen" checks, not for bad input.

By convention, the unit tests of a file go into a module called tests at the bottom of that same file. The attribute #[cfg(test)] means the module is only compiled when tests are built, so the tests add nothing to the finished program. use super::*; brings the functions of the surrounding module into scope. Because the tests are inside the module, they can also test private functions.

/// CRC-16/ARC: reflected polynomial 0xA001, start value 0, no final XOR.
pub fn crc16_arc(data: &[u8]) -> u16 {
    let mut crc: u16 = 0;
    for &byte in data {
        crc ^= u16::from(byte);
        for _ in 0..8 {
            crc = if crc & 1 != 0 { (crc >> 1) ^ 0xA001 } else { crc >> 1 };
        }
    }
    crc
}

/// The CRC field after the `!`: one to four hex digits, then CR LF.
pub fn sent_crc(tail: &str) -> Option<u16> {
    let digits = tail.strip_suffix("\r\n")?;
    if digits.is_empty() || digits.len() > 4 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
        return None;
    }
    u16::from_str_radix(digits, 16).ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn check_value() {
        assert_eq!(crc16_arc(b"123456789"), 0xBB3D);
    }

    #[test]
    fn three_digit_crc_is_accepted() {
        assert_eq!(sent_crc("B9F\r\n"), Some(0x0B9F));
    }

    #[test]
    fn plus_sign_is_rejected() {
        // u16::from_str_radix alone would accept "+B9F".
        assert_eq!(sent_crc("+B9F\r\n"), None, "a leading + is not a hex digit");
    }
}
running 3 tests
test tests::check_value ... ok
test tests::plus_sign_is_rejected ... ok
test tests::three_digit_crc_is_accepted ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Each line names the test by its path (tests::check_value) and its result; the summary counts passed, failed and ignored tests. The first test checks the published check value of the algorithm, the CRC of the ASCII string 123456789 (see Checking the CRC). The third one is the interesting one: it pins down a detail that the type system does not catch on its own, because u16::from_str_radix("+B9F", 16) returns Ok(2975). Only the explicit digit check rejects it. A test like this documents the decision and keeps a later "cleanup" from undoing it.

A failing test

A test that fails shows the panic message, and for assert_eq! both values. Suppose the first version of the prototype only accepted uppercase digits, and a test checks that a lowercase CRC is accepted, as the manufacturer's fix does (it accepts [0-9A-Fa-f]):

pub fn sent_crc(tail: &str) -> Option<u16> {
    let digits = tail.strip_suffix("\r\n")?;
    let uppercase_hex = |b: u8| b.is_ascii_digit() || (b'A'..=b'F').contains(&b);
    if digits.is_empty() || digits.len() > 4 || !digits.bytes().all(uppercase_hex) {
        return None;
    }
    u16::from_str_radix(digits, 16).ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn uppercase_crc_is_accepted() {
        assert_eq!(sent_crc("B9F\r\n"), Some(0x0B9F));
    }

    #[test]
    fn lowercase_crc_is_accepted() {
        assert_eq!(sent_crc("b9f\r\n"), Some(0x0B9F));
    }
}
running 2 tests
test tests::lowercase_crc_is_accepted ... FAILED
test tests::uppercase_crc_is_accepted ... ok

failures:

---- tests::lowercase_crc_is_accepted stdout ----

thread 'tests::lowercase_crc_is_accepted' (3172743) panicked at example.rs:21:9:
assertion `left == right` failed
  left: None
 right: Some(2975)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::lowercase_crc_is_accepted

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

assert_eq! prints the two values in their Debug form, so 0x0B9F appears as the decimal 2975; the line number points at the assertion that failed. The test binary exits with status 101, which is what makes a pipeline stop. Whether lowercase digits should be accepted is a decision, not a fact the compiler can know; the failing test makes the difference between the prototype and the C fix visible.

Expected panics, ignored tests and Result

Three variations cover most remaining needs. #[should_panic] turns the logic around: the test passes only if the code panics, and with expected = "…" only if the panic message contains that text. #[ignore] skips a test unless you ask for it, for example one that needs real hardware. And a test may return a Result, so the question mark operator can be used inside it; returning Err fails the test.

pub fn average_kwh(readings: &[u32]) -> u32 {
    let total: u32 = readings.iter().sum();
    total / readings.len() as u32
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::num::ParseIntError;

    #[test]
    fn average_of_three() {
        assert_eq!(average_kwh(&[350, 412, 120]), 294);
    }

    #[test]
    #[should_panic(expected = "divide by zero")]
    fn no_readings_panics() {
        average_kwh(&[]);
    }

    #[test]
    fn parses_a_reading() -> Result<(), ParseIntError> {
        let kwh: u32 = "350".parse()?;
        assert_eq!(average_kwh(&[kwh]), 350);
        Ok(())
    }

    #[test]
    #[ignore = "needs a meter on the serial port"]
    fn reads_a_real_meter() {
        // would open /dev/ttyUSB0 here
    }
}
running 4 tests
test tests::average_of_three ... ok
test tests::no_readings_panics - should panic ... ok
test tests::parses_a_reading ... ok
test tests::reads_a_real_meter ... ignored, needs a meter on the serial port

test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

should_panic is a blunt tool: it passes for any panic with a matching message, also one from a different line than you meant. For functions that report errors with Result, test the returned Err value instead. Ignored tests run with cargo test -- --ignored.

Running tests with cargo test

In a Cargo project, cargo test does all the work: it compiles the package in the test profile, runs the unit tests of the library and of each binary, then every integration test file in tests/, and finally the examples in the documentation comments. Here is the complete p1crc prototype with all three kinds. The example creates the project in an empty folder (see Installing Rust and Using Cargo for cargo new):

cargo new -q --vcs none --lib p1crc
cd p1crc
cat > src/lib.rs <<'EOF'
//! CRC-16/ARC checks for DSMR P1 telegrams (a prototype).

/// Computes CRC-16/ARC: reflected polynomial 0xA001, start value 0,
/// no final XOR.
///
/// # Examples
///
/// ```
/// assert_eq!(p1crc::crc16_arc(b"123456789"), 0xBB3D);
/// ```
pub fn crc16_arc(data: &[u8]) -> u16 {
    let mut crc: u16 = 0;
    for &byte in data {
        crc ^= u16::from(byte);
        for _ in 0..8 {
            crc = if crc & 1 != 0 { (crc >> 1) ^ 0xA001 } else { crc >> 1 };
        }
    }
    crc
}

/// Parses the CRC field after the `!` of a telegram: one to four
/// hexadecimal digits, then CR LF. Anything else gives `None`.
///
/// # Examples
///
/// ```
/// use p1crc::sent_crc;
///
/// assert_eq!(sent_crc("B9F\r\n"), Some(0x0B9F));
/// assert_eq!(sent_crc("+B9F\r\n"), None);
/// ```
pub fn sent_crc(tail: &str) -> Option<u16> {
    let digits = tail.strip_suffix("\r\n")?;
    if digits.is_empty() || digits.len() > 4 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
        return None;
    }
    u16::from_str_radix(digits, 16).ok()
}

/// Checks a whole telegram: the CRC covers everything up to and
/// including the `!`.
pub fn verify(telegram: &str) -> bool {
    let Some(end) = telegram.find('!') else {
        return false;
    };
    let (body, tail) = telegram.split_at(end + 1);
    sent_crc(tail) == Some(crc16_arc(body.as_bytes()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn check_value() {
        assert_eq!(crc16_arc(b"123456789"), 0xBB3D);
    }

    #[test]
    fn three_digit_crc_is_accepted() {
        assert_eq!(sent_crc("B9F\r\n"), Some(0x0B9F));
    }
}
EOF
mkdir tests
cat > tests/telegram.rs <<'EOF'
const TELEGRAM: &str = "/KST5\r\n\r\n1-0:1.7.0(01.234*kW)\r\n!9D5C\r\n";

#[test]
fn intact_telegram_passes() {
    assert!(p1crc::verify(TELEGRAM));
}

#[test]
fn changed_value_fails() {
    let tampered = TELEGRAM.replace("01.234", "01.284");
    assert!(!p1crc::verify(&tampered));
}
EOF
cargo test -- --test-threads=1
   Compiling p1crc v0.1.0 (/home/amara/p1crc)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running unittests src/lib.rs (target/debug/deps/p1crc-cb61083408d7e765)

running 2 tests
test tests::check_value ... ok
test tests::three_digit_crc_is_accepted ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/telegram.rs (target/debug/deps/telegram-43b07a7ba4cae482)

running 2 tests
test changed_value_fails ... ok
test intact_telegram_passes ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests p1crc

running 2 tests
test src/lib.rs - crc16_arc (line 8) ... ok
test src/lib.rs - sent_crc (line 27) ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

all doctests ran in 0.34s; merged doctests compilation took 0.33s

The output has three blocks. unittests src/lib.rs is the test binary built from the library with its tests module; tests/telegram.rs is the integration test; Doc-tests p1crc are the two examples in the documentation comments, named after the file, the item and the line where the example starts. The status lines (Compiling, Finished, Running) go to standard error, the test results to standard output. The short telegram in the integration test is an invented example, not a real meter's telegram; its CRC was computed for this page.

Choosing what runs

Arguments before -- go to Cargo, arguments after it go to the test binaries:

cargo test crc                     # only tests whose name contains "crc"
cargo test --test telegram         # only the integration test file tests/telegram.rs
cargo test --lib                   # only the library's unit tests
cargo test --doc                   # only the doc tests
cargo test --no-fail-fast          # keep going after a failing test binary
cargo test -q                      # one dot per test instead of one line
cargo test -- --ignored            # run only the ignored tests
cargo test -- --test-threads=1     # one test at a time, in a fixed order
cargo test -- --nocapture          # show what passing tests print

By default the tests of one binary run in parallel on several threads, so the order of the result lines can change from run to run, and two tests that use the same file or port can disturb each other. Tests should not depend on each other; when they have to share something, --test-threads=1 runs them one at a time (the examples on this site use it to get a stable order). Output that a passing test prints with println! is captured and only shown when the test fails. When a unit test fails, Cargo stops after that test binary: the integration and doc tests are not run unless you pass --no-fail-fast.

Integration tests

Files in the tests/ folder next to src/ are integration tests. Cargo compiles each file as a separate crate that uses the library like any other user would: only through its public items (p1crc::verify), without access to private functions. That makes them the right place to test the library's interface as a whole, such as "an intact telegram passes, a changed value fails", while the unit tests check the pieces. Each file in tests/ becomes its own test binary; helper code shared by several files goes in a subfolder such as tests/common/mod.rs, which Cargo does not treat as a test file.

Rust has no built-in mocking. Code that talks to a serial port, a database or a web service is made testable by passing in a value that implements a trait; the test passes a simple fake instead of the real thing, the idea behind every test double. Crates such as mockall generate such fakes when writing them by hand gets tedious.

Documentation comments

A documentation comment starts with /// and documents the item that follows it; //! documents the enclosing item, usually the whole crate at the top of lib.rs. Their content is Markdown (see What Is Markdown?). Ordinary comments (//) are for readers of the code; documentation comments are for users of the API. Conventional headings tell users what they need before they call a function:

Heading What goes under it
# Examples one or more code examples (these become doc tests)
# Errors when a function returns Err, and which errors
# Panics the situations in which the function panics
# Safety for an unsafe fn: what the caller must guarantee (see Unsafe Rust and Calling C)

cargo doc runs rustdoc and writes a static website to target/doc/, with one page per item, a search box and links between types; cargo doc --open opens it in a browser. It works offline and needs nothing beyond Cargo:

cargo new -q --vcs none --lib p1crc
cd p1crc
cat > src/lib.rs <<'EOF'
//! CRC-16/ARC checks for DSMR P1 telegrams (a prototype).

/// Computes CRC-16/ARC: reflected polynomial 0xA001, start value 0,
/// no final XOR.
pub fn crc16_arc(data: &[u8]) -> u16 {
    let mut crc: u16 = 0;
    for &byte in data {
        crc ^= u16::from(byte);
        for _ in 0..8 {
            crc = if crc & 1 != 0 { (crc >> 1) ^ 0xA001 } else { crc >> 1 };
        }
    }
    crc
}
EOF
cargo doc
ls target/doc/p1crc
 Documenting p1crc v0.1.0 (/home/amara/p1crc)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
   Generated /home/amara/p1crc/target/doc/p1crc/index.html
all.html
fn.crc16_arc.html
index.html
sidebar-items.js

For crates published on crates.io, the site docs.rs builds and hosts this documentation automatically for every version, which is why most Rust libraries have browsable API documentation in the same layout. The documentation of the standard library itself is the rust-docs component of the toolchain; it is installed with rustup's default profile but not with the minimal one, which is what this site's test machine uses:

rustup doc --std
info: `rust-docs` not installed in toolchain `stable-aarch64-unknown-linux-gnu`
error: unable to view documentation which is not installed

rustup component add rust-docs installs it; the same documentation is online at doc.rust-lang.org.

Doc tests

Every code block in a documentation comment is a doc test: cargo test compiles it as a small program that uses the crate, and runs it. An example that no longer compiles, or whose assertion fails, fails the build. Documentation examples are notorious for going stale when the code changes; in Rust that shows up as a red test instead of a confused user. Each example is written from the user's point of view, with the crate's name in the path (p1crc::crc16_arc) or a use line, exactly as a caller would write it.

A few markers change how a block is treated: a line starting with # is compiled but hidden in the rendered documentation (useful for setup code); ```no_run compiles but does not run the example (it might open a serial port); ```should_panic expects a panic; ```ignore skips it entirely, and ```text marks a block that is not Rust at all. Doc tests are only run for library crates. Since the 2024 edition, rustdoc compiles all doc tests of a crate into one program where it can, which is why the output above ends with "merged doctests compilation took …"; with many examples this is much faster than compiling each one on its own.

Lints: warnings that find bugs

A lint in Rust is a named check that the compiler or Clippy runs on code that is valid but suspicious, such as an unused variable, an unreachable branch or an ignored error. Each lint has a level: allow (off), warn, deny (an error) or forbid (an error that cannot be allowed again further down). rustc's own lints are on from the start, and some of them catch real bugs. Ignoring a Result is one: the type is marked #[must_use], so a call whose error is silently dropped produces the unused_must_use warning. This site builds every example with -D warnings, which turns all warnings into errors, the way a strict pipeline does:

fn save(kwh: u32) -> Result<(), String> {
    if kwh > 100_000 {
        return Err(format!("{kwh} kWh is not a plausible reading"));
    }
    Ok(())
}

fn main() {
    save(350);
    println!("saved");
}
example.rs:9:5: error: unused `Result` that must be used

Without -D warnings the program builds, with this warning:

warning: unused `Result` that must be used
 --> example.rs:9:5
  |
9 |     save(350);
  |     ^^^^^^^^^
  |
  = note: this `Result` may be an `Err` variant, which should be handled
  = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
  |
9 |     let _ = save(350);
  |     +++++++

warning: 1 warning emitted

The fix is to handle the error (save(350)? in a function that returns Result, or a match), or, if dropping it is really intended, to say so with let _ = save(350);. Levels can be set per item with attributes, or for the whole crate at the top of lib.rs or main.rs:

//! Totals meter readings (an example).
#![deny(unused_must_use)]   // whole crate: an ignored Result is an error
#![warn(missing_docs)]      // whole crate: warn about undocumented public items

#[allow(dead_code)]         // only this function: it is not used yet
fn legacy_tariff_code() {}

fn main() {}

#[expect(lint, reason = "…")] (stable since Rust 1.81) is a better allow: it silences the lint, and warns if the lint no longer fires, so outdated exceptions do not pile up. Keep exceptions as narrow as possible, on one item, with a reason.

Clippy

Clippy is Rust's official linter: a component of the toolchain (installed by rustup's default profile) that runs the compiler's analysis with more than 800 additional lints. cargo clippy checks the project without building a binary. Its lints come in groups: correctness (code that is almost certainly wrong; errors by default), suspicious, style, complexity and perf (warnings by default), and the opt-in groups pedantic, restriction, nursery and cargo. This version of a meter-reading tool compiles without any warning from rustc:

fn total_kwh(readings: &Vec<u32>) -> u32 {
    let mut total = 0;
    for i in 0..readings.len() {
        total += readings[i];
    }
    total
}

fn main() {
    let readings = vec![350, 412, 120];
    if readings.len() == 0 {
        println!("no readings");
    }
    println!("total: {} kWh", total_kwh(&readings));
}
cargo clippy
    Checking kestrel-readings v0.1.0 (/home/amara/kestrel-readings)
warning: writing `&Vec` instead of `&[_]` involves a new object where a slice will do
 --> src/main.rs:1:24
  |
1 | fn total_kwh(readings: &Vec<u32>) -> u32 {
  |                        ^^^^^^^^^
  |
  = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#ptr_arg
  = note: `#[warn(clippy::ptr_arg)]` on by default
help: change this to
  |
1 - fn total_kwh(readings: &Vec<u32>) -> u32 {
1 + fn total_kwh(readings: &[u32]) -> u32 {
  |

warning: the loop variable `i` is only used to index `readings`
 --> src/main.rs:3:14
  |
3 |     for i in 0..readings.len() {
  |              ^^^^^^^^^^^^^^^^^
  |
  = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#needless_range_loop
  = note: `#[warn(clippy::needless_range_loop)]` on by default
help: consider using an iterator
  |
3 -     for i in 0..readings.len() {
3 +     for <item> in &readings {
  |

warning: length comparison to zero
  --> src/main.rs:11:8
   |
11 |     if readings.len() == 0 {
   |        ^^^^^^^^^^^^^^^^^^^ help: using `is_empty` is clearer and more explicit: `readings.is_empty()`
   |
   = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#len_zero
   = note: `#[warn(clippy::len_zero)]` on by default

warning: `kestrel-readings` (bin "kestrel-readings") generated 3 warnings (run `cargo clippy --fix --bin "kestrel-readings" -p kestrel-readings -- ` to apply 1 suggestion)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s

None of the three is a bug here, but each one makes the code simpler or more general: a function that takes &[u32] also accepts arrays and parts of vectors (see Collections and Strings); an iterator loop cannot index out of bounds (see Iterators and Closures); and is_empty() says what is meant. Every warning names its lint and links to an explanation with examples. The link contains the toolchain version, so it changes with every Rust update.

cargo clippy --fix applies the suggestions that are safe to apply automatically (here one of the three) and leaves the rest to you; run it on a clean working tree so the changes are easy to review. In a pipeline, cargo clippy -- -D warnings turns every warning into an error and makes the command fail. Lint levels for Clippy work like rustc's, with the clippy:: prefix, for example #[expect(clippy::needless_range_loop, reason = "…")] on one function. A [lints] table in Cargo.toml sets levels for the whole package:

[lints.rust]
unsafe_code = "forbid"

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

unsafe_code = "forbid" is a common choice for crates that should never contain unsafe code. The priority = -1 lets single lints override the group setting.

Formatting with rustfmt

rustfmt is Rust's official code formatter: it rewrites the layout of the code (indentation, spaces, line breaks, at most 100 characters per line by default) according to the Rust style guide, without changing what the code does. cargo fmt formats every file of the package; cargo fmt --check changes nothing and only reports, with a diff and exit status 1, which is how a pipeline checks that code was formatted:

cargo new -q --vcs none kestrel-readings
cd kestrel-readings
cat > src/main.rs <<'EOF'
fn main(){
let readings=[350,412,120];
    let total:u32=readings.iter().sum();
  println!("total: {total} kWh");
}
EOF
cargo fmt --check || echo "exit status: $?"
cargo fmt
cargo fmt --check && echo "formatted"
cat src/main.rs
Diff in /home/amara/kestrel-readings/src/main.rs:1:
-fn main(){
-let readings=[350,412,120];
-    let total:u32=readings.iter().sum();
-  println!("total: {total} kWh");
+fn main() {
+    let readings = [350, 412, 120];
+    let total: u32 = readings.iter().sum();
+    println!("total: {total} kWh");
 }

exit status: 1
formatted
fn main() {
    let readings = [350, 412, 120];
    let total: u32 = readings.iter().sum();
    println!("total: {total} kWh");
}

Because practically every Rust project uses rustfmt with the default settings, Rust code looks the same everywhere, and code reviews are about content instead of brace placement. A rustfmt.toml file can change a few settings (such as max_width), but most projects do without one. Since Rust 2024, rustfmt has style editions: the formatting style follows the crate's edition by default, so larger changes to the style arrive with a new style edition that a project chooses, rather than with an ordinary toolchain update. Most editors can run rustfmt on save, usually through rust-analyzer.

More tools around the toolchain

Beyond what ships with Cargo, a few community tools are common enough to recognize by name. None of them is installed on this site's test machine, so there is no recorded output here.

Tool What it does Background
rust-analyzer language server: completion, inline types and errors, go-to-definition in VS Code, Vim, Emacs and other editors a rustup component (rustup component add rust-analyzer)
proptest, quickcheck generate many random inputs and shrink failures Property-Based Testing
cargo-fuzz feeds a function with mutated inputs (libFuzzer) to find panics and crashes; a natural fit for a telegram parser Fuzzing
cargo-llvm-cov, cargo-tarpaulin code coverage of the tests Code Coverage and Mutation Testing
cargo-mutants mutation testing: do the tests notice a changed line? Mutation testing
criterion statistically sound benchmarks performance comparisons between versions
Miri runs tests in an interpreter that detects undefined behavior in unsafe code (nightly toolchain only) Unsafe Rust and Calling C

Tools installed with cargo install are downloaded from crates.io and compiled on your machine, build scripts included, so they deserve the same care as any other dependency (see The Crate Ecosystem and Supply-Chain Safety).

Putting it together in a pipeline

The commands on this page are fast enough to run on every push. A typical order puts the cheapest checks first, so a formatting slip fails in seconds:

cargo fmt --check                              # formatted? (exit 1 if not)
cargo clippy --all-targets --locked -- -D warnings   # lints, also in tests; warnings fail
cargo test --locked                            # unit, integration and doc tests
cargo doc --no-deps                            # documentation builds

--all-targets lints the tests and examples too; --locked refuses to build if Cargo.lock would have to change, so the pipeline tests exactly the dependency versions that were committed (see Cargo.lock). Each command exits with a non-zero exit status on failure, which is all a CI system needs; the general setup of such a pipeline is on Testing and Quality in Pipelines. Pinning the toolchain version keeps a new Clippy release from failing the build on a Monday morning; see a dependency policy.

For the p1crc prototype in this example, that means: the CRC check value, the three-digit CRC from BUG-1060, the rejected + sign and the tampered telegram are all tests that run on every change. When the prototype and the manufacturer's C fix disagree, as with the lowercase digits above, a test makes the difference concrete enough to discuss.