Repository: knurling-rs/defmt (rust)

Issue:
turn CI logic into xtasks
would be nice if there were xtasks to run the CI tests locally.
right now CI logic is in a yaml file that contains bash-like stuff and the tests.sh bash file (which won't run on Windows)
Ideally I would like the GHA yaml be more or less just a call of different xtasks
``` yaml
# apt-get install stuff has been omitted
jobs:
  host:
    # omitted: OS matrix
    name: run unit tests on the host
    run: cargo xtask test host

  cross:
    name: check cross compilation
    run: cargo xtask test cross

  snapshot:
    name: run snapshot tests (QEMU)
    run: cargo xtask test snapshot

  book:
    name: run mdbook tests
    run: cargo xtask test book

  lint:
    name: run rustfmt and clippy
    run: cargo xtask test lint
```

and then a single xtask, e.g. `cargo xtask test all`, that sort of runs all the above jobs which is what contributors would run locally to check their branch / PR is "green".
the xtasks should auto-install the cross compilation targets (`rustup target add`) so that contributors don't need to do "setup" steps
(maybe the xtasks should uninstall the cross compilation targets that were not there when they exit, regardless of the exit code)

Pull request description:
implement CI tests as xtask
this xtask implementation fixes #366 and
- collects all test errors in one grand result vec and displays them at the end
- has commands for `test-all`, `test-host`, `test-cross` etc.
- has `-k` switch to keep targets that were installed as required dependency
- displays colored diff for failed snapshot tests
- puts "macos book build fixes" in separate commit ( c847772 ) - please review
- updates the ci yaml to use this xtask

not done:
- install os specific packages like `libusb` (out of scope, IMO)

Patch:
diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..35049cb
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,2 @@
+[alias]
+xtask = "run --package xtask --"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8837b81..75229d7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,7 @@ name: CI
 
 on:
   push:
-    branches: [ main, staging, trying ]
+    branches: [main, staging, trying]
   pull_request:
     branches: [ main ]
   schedule:
@@ -12,11 +12,11 @@ on:
 env:
   CARGO_TERM_COLOR: always
   NO_STD_CHECK_TARGETS: thumbv6m-none-eabi thumbv8m.base-none-eabi riscv32i-unknown-none-elf
-  NO_STD_TARGET: thumbv7em-none-eabi  # firmware uses atomics
+  NO_STD_TARGET: thumbv7em-none-eabi # firmware uses atomics
   QEMU_TARGET: thumbv7m-none-eabi
 
 jobs:
-  test:
+  host:
     strategy:
       matrix:
         rust:
@@ -29,89 +29,50 @@ jobs:
     runs-on: ${{ matrix.os }}
     timeout-minutes: 20
     steps:
-    - uses: actions/checkout@v2
-    - uses: actions-rs/toolchain@v1
-      with:
-        profile: minimal
-        toolchain: ${{ matrix.rust }}
-        override: true
-    - name: Install C libraries for tooling on ubuntu
-      if: matrix.os == 'ubuntu-latest'
-      run: sudo apt-get update && sudo apt-get install libudev-dev libusb-1.0-0-dev
-    - name: Install C libraries for tooling on macOS
-      if: matrix.os == 'macOS-latest'
-      run: brew install libusb
-    - name: Check that all crates that can be compiled for the host build
-      run: RUSTFLAGS='--deny warnings' cargo check --workspace
-      shell: bash
-    - name: Check that defmt compiles with different features
-      run: |
-        RUSTFLAGS='--deny warnings' cargo check --all --features unstable-test
-        RUSTFLAGS='--deny warnings' cargo check --all --features alloc
-      shell: bash
-    - name: Run all unit tests on the host
-      run: cargo test --workspace --features unstable-test
-
-  no-std:
-    runs-on: ubuntu-latest
-    steps:
-    - uses: actions/checkout@v2
-    - uses: actions-rs/toolchain@v1
-      with:
-        profile: minimal
-        toolchain: stable
-        override: true
-        target: ${{ env.NO_STD_TARGET }}
-    - name: Install Rust targets
-      run: rustup +stable target add ${{ env.NO_STD_TARGET }} ${{ env.NO_STD_CHECK_TARGETS }}
-    - name: Build defmt crates for no_std targets
-      run: |
-        for target in $NO_STD_CHECK_TARGETS; do
-          cargo check --target $target -p defmt
-          cargo check --target $target -p defmt --features alloc
-        done
-    - name: Build defmt dependent crates for cortex-m targets
-      working-directory: firmware
-      # some crates will not compile to targets with no CAS support
-      run: |
-        cargo check --target thumbv6m-none-eabi --workspace --exclude defmt-itm --exclude firmware
-        cargo check --target thumbv7em-none-eabi --workspace
-    - name: Build panic-probe with different features
-      working-directory: firmware/panic-probe
-      run: |
-        cargo check --target thumbv6m-none-eabi --features print-defmt
-        cargo check --target thumbv6m-none-eabi --features print-rtt
+      - uses: actions/checkout@v2
+      - uses: actions-rs/toolchain@v1
+        with:
+          profile: minimal
+          toolchain: ${{ matrix.rust }}
+          override: true
+      - name: Install C libraries for tooling on ubuntu
+        if: matrix.os == 'ubuntu-latest'
+        run: sudo apt-get update && sudo apt-get install libudev-dev libusb-1.0-0-dev
+      - name: Install C libraries for tooling on macOS
+        if: matrix.os == 'macOS-latest'
+        run: brew install libusb
+      - name: Check that all crates that can be compiled for the host build, check that defmt compiles with different features, run all unit tests on the host
+        run: cargo xtask -d test-host
 
-  clippy:
+  cross:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v2
       - uses: actions-rs/toolchain@v1
         with:
+          profile: minimal
           toolchain: stable
           override: true
-          profile: minimal
-          components: clippy
-      - uses: actions-rs/clippy-check@v1
-        with:
-          token: ${{ secrets.GITHUB_TOKEN }}
-          args: >
-            --workspace
+          target: ${{ env.NO_STD_TARGET }}
+      - name: Install Rust targets, build defmt crates for no_std targets, build defmt dependent crates for cortex-m targets, build panic-probe with different features
+        run: cargo xtask test-cross
 
-  rustfmt:
+  lint:
+    strategy:
+      matrix:
+        rust:
+          - stable
     runs-on: ubuntu-latest
+    timeout-minutes: 10
     steps:
       - uses: actions/checkout@v2
       - uses: actions-rs/toolchain@v1
         with:
-          toolchain: stable
-          override: true
           profile: minimal
-          components: rustfmt
-      - uses: actions-rs/cargo@v1
-        with:
-          command: fmt
-          args: --all -- --check
+          toolchain: ${{ matrix.rust }}
+          override: true
+      - name: Run rustfmt & clippy
+        run: cargo xtask test-lint
 
   mdbook:
     strategy:
@@ -120,28 +81,26 @@ jobs:
           - stable
         os:
           - ubuntu-latest
-          - windows-latest
+          # as of 2021-04 github SIGKILLs us on macos for unknown reasons (possible OOM)
+          # - macOS-latest
     runs-on: ${{ matrix.os }}
     timeout-minutes: 10
     steps:
-    - uses: actions/checkout@v2
-    - uses: actions-rs/toolchain@v1
-      with:
-        profile: minimal
-        toolchain: ${{ matrix.rust }}
-        override: true
-    - name: Setup mdBook
-      uses: peaceiris/actions-mdbook@v1
-      with:
-        mdbook-version: latest
-    - name: Build defmt
-      run: cargo build --features unstable-test
-      # NOTE order of steps is important
-    - name: Run book tests
-      working-directory: book
-      run: mdbook test -L ../target/debug -L ../target/debug/deps
+      - uses: actions/checkout@v2
+      - uses: actions-rs/toolchain@v1
+        with:
+          profile: minimal
+          toolchain: ${{ matrix.rust }}
+          override: true
+      - name: Setup mdBook
+        uses: peaceiris/actions-mdbook@v1
+        with:
+          mdbook-version: latest
+      - name: Run book tests
+        timeout-minutes: 10
+        run: cargo xtask test-book
 
-  qemu:
+  qemu-snapshot:
     strategy:
       matrix:
         rust:
@@ -149,39 +108,36 @@ jobs:
           - nightly
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v2
-    - uses: actions-rs/toolchain@v1
-      with:
-        profile: minimal
-        toolchain: ${{ matrix.rust }}
-        override: true
-        target: ${{ env.QEMU_TARGET }}
-    - name: Install dependencies
-      run: sudo apt-get update && sudo apt-get install qemu qemu-system-arm
-    - name: Build and Run QEMU tests
-      working-directory: firmware/qemu
-      run: ./test.sh
-    - name: Backward compatibility check against decoder v0.2.0
-      working-directory: firmware/qemu
-      env:
-        CARGO_TARGET_THUMBV7M_NONE_EABI_RUNNER: qemu-run
-        QEMU_RUN_IGNORE_VERSION: 1
-      run: |
-        cargo install --debug --git https://github.com/knurling-rs/defmt --rev v0.2.0-with-qemu-run-ignore-version qemu-run
-        ./test.sh
-
+      - uses: actions/checkout@v2
+      - uses: actions-rs/toolchain@v1
+        with:
+          profile: minimal
+          toolchain: ${{ matrix.rust }}
+          override: true
+          target: ${{ env.QEMU_TARGET }}
+      - name: Install dependencies
+        run: sudo apt-get update && sudo apt-get install qemu qemu-system-arm
+      - name: Run QEMU snapshot tests
+        run: cargo xtask test-snapshot
+      - name: install decoder v0.2.0
+        working-directory: firmware/qemu
+        run: cargo install --debug --git https://github.com/knurling-rs/defmt --rev v0.2.0-with-qemu-run-ignore-version qemu-run
+      - name: Backward compatibility check against decoder v0.2.0
+        env:
+          CARGO_TARGET_THUMBV7M_NONE_EABI_RUNNER: qemu-run
+          QEMU_RUN_IGNORE_VERSION: 1
+        run: cargo xtask test-snapshot
   # Refs: https://github.com/rust-lang/crater/blob/9ab6f9697c901c4a44025cf0a39b73ad5b37d198/.github/workflows/bors.yml#L125-L149
   # bors.tech integration
   ci-success:
     name: ci
     if: ${{ success() }}
     needs:
-      - test
-      - no-std
-      - clippy
-      - rustfmt
+      - host
+      - cross
+      - lint
       - mdbook
-      - qemu
+      - qemu-snapshot
     runs-on: ubuntu-20.04
     steps:
       - name: CI succeeded
diff --git a/Cargo.toml b/Cargo.toml
index bb7eda8..fa89586 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,6 +33,7 @@ members = [
   "parser",
   "print",
   "qemu-run",
+  "xtask",
 ]
 exclude = [
   "firmware/*",
diff --git a/book/src/duplicates.md b/book/src/duplicates.md
index ae36df6..c32b130 100644
--- a/book/src/duplicates.md
+++ b/book/src/duplicates.md
@@ -17,7 +17,7 @@ This produces two symbols with the name "X".
 How can this occur in logging?
 The user may write:
 
-``` rust
+``` rust,no_run,noplayground
 # extern crate defmt;
 fn foo() {
     defmt::info!("foo started ..");
@@ -34,7 +34,7 @@ fn bar() {
 
 Because macros are expanded in isolation *each* `info!(".. DONE")` statement will produce this to intern its string:
 
-``` rust
+``` rust,no_run,noplayground
 #[export_name = ".. DONE"]
 #[link_section = ".."]
 static SYM: u8 = 0;
@@ -46,7 +46,7 @@ To avoid this issue we store each interned string as a JSON object with 3 fields
 The discriminator is a hash of the source code location of the log statement so it should be unique per crate.
 Now these two macro invocations will produce something like this:
 
-``` rust
+``` rust,no_run,noplayground
 // first info! invocation
 {
     #[export_name = "{ \"package\": \"my-app\", \"data\": \".. DONE\", \"discriminator\": \"1379186119\" }"]
diff --git a/book/src/interning.md b/book/src/interning.md
index 2f876a6..6a45efc 100644
--- a/book/src/interning.md
+++ b/book/src/interning.md
@@ -6,7 +6,7 @@ It is not without downsides as we'll see.
 
 The basic pattern for interning a string is this:
 
-``` rust
+``` rust,no_run,noplayground
 #[export_name = "the string that will be interned"]
 #[link_section = ".my_custom_section.some_unique_identifier"]
 //             ^ this is the INPUT linker section
diff --git a/book/src/linker-sections.md b/book/src/linker-sections.md
index abe5c86..b5e719d 100644
--- a/book/src/linker-sections.md
+++ b/book/src/linker-sections.md
@@ -42,11 +42,11 @@ defmt::error!("Bye");
 
 Would expand to this:
 
-``` rust
+``` rust,no_run,noplayground
 // first warn! invocation
 {
-    #[export_name = "Hello@1379186119"]
-    #[link_section = ".defmt.warn.1379186119"]
+    #[export_name = "{\"package\":\"my-app\",\"tag\":\"defmt_warn\",\"data\":\"Hello\",\"disambiguator\":\"8864866341617976971\"}"]
+    #[link_section = ".defmt.{\"package\":\"my-app\",\"tag\":\"defmt_warn\",\"data\":\"Hello\",\"disambiguator\":\"8864866341617976971\"}"]
     static SYM: u8 = 0;
 }
 
@@ -54,8 +54,8 @@ Would expand to this:
 
 // first error! invocation
 {
-    #[export_name = "Bye@346188945"]
-    #[link_section = ".defmt.error.346188945"]
+    #[export_name = "{\"package\":\"my-app\",\"tag\":\"defmt_error\",\"data\":\"Bye\",\"disambiguator\":\"2879057613697528561\"}"]
+    #[link_section = ".defmt.{\"package\":\"my-app\",\"tag\":\"defmt_error\",\"data\":\"Bye\",\"disambiguator\":\"2879057613697528561\"}"]
     static SYM: u8 = 0;
 }
 ```
diff --git a/firmware/qemu/.cargo/config b/firmware/qemu/.cargo/config
index c9acc5d..0a837af 100644
--- a/firmware/qemu/.cargo/config
+++ b/firmware/qemu/.cargo/config
@@ -1,10 +1,10 @@
 [alias]
-rb = "run --target thumbv7m-none-eabi --bin"
-rrb = "run --target thumbv7m-none-eabi --release --bin"
+rb = "-q run --target thumbv7m-none-eabi --bin"
+rrb = "-q run --target thumbv7m-none-eabi --release --bin"
 
 [target.'cfg(all(target_arch = "arm", target_os = "none"))']
 # runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"
-runner = "cargo run --manifest-path ../../qemu-run/Cargo.toml"
+runner = "cargo -q run --manifest-path ../../qemu-run/Cargo.toml"
 
 rustflags = [
   # LLD (shipped with the Rust toolchain) is used as the default linker
diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
new file mode 100644
index 0000000..75f1777
--- /dev/null
+++ b/xtask/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "xtask"
+authors = ["The Knurling-rs developers"]
+edition = "2018"
+license = "MIT OR Apache-2.0"
+publish = false
+version = "0.1.0"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+anyhow = "1.0"
+console = "0.14"
+once_cell = "1.7"
+similar = "1.1"
+structopt = "0.3"
\ No newline at end of file
diff --git a/xtask/rustfmt.toml b/xtask/rustfmt.toml
new file mode 100644
index 0000000..6ce1513
--- /dev/null
+++ b/xtask/rustfmt.toml
@@ -0,0 +1,1 @@
+max_width = 115
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
new file mode 100644
index 0000000..e69b469
--- /dev/null
+++ b/xtask/src/main.rs
@@ -0,0 +1,469 @@
+use std::{
+    collections::{HashMap, HashSet},
+    fs,
+    path::Path,
+    process::Command,
+    str,
+    sync::Mutex,
+};
+
+use anyhow::{anyhow, Context, Result};
+use console::Style;
+use once_cell::sync::Lazy;
+use similar::{ChangeTag, TextDiff};
+use structopt::StructOpt;
+
+static ALL_ERRORS: Lazy<Mutex<Vec<String>>> = Lazy::new(|| Mutex::new(vec![]));
+
+#[derive(Debug, StructOpt)]
+struct Options {
+    #[structopt(long, short, help = "keep target toolchains that were installed as dependency")]
+    keep_targets: bool,
+
+    #[structopt(
+        long,
+        short,
+        help = "treat compiler warnings as errors (RUSTFLAGS=\"--deny warnings\")"
+    )]
+    deny_warnings: bool,
+
+    #[structopt(subcommand)]
+    cmd: TestCommand,
+}
+
+#[derive(StructOpt, Debug)]
+#[allow(clippy::enum_variant_names)]
+enum TestCommand {
+    TestAll,
+    TestBook,
+    TestCross,
+    TestHost,
+    TestLint,
+    TestSnapshot,
+}
+
+fn run_command(cmd_and_args: &[&str], cwd: Option<&str>, env: &[(&str, &str)]) -> Result<()> {
+    let cmd_and_args = Vec::from(cmd_and_args);
+    let mut cmd = &mut Command::new(cmd_and_args[0]);
+    if cmd_and_args.len() > 1 {
+        cmd.args(&cmd_and_args[1..]);
+    }
+
+    for (k, v) in env {
+        cmd.env(k, v);
+    }
+
+    let cwd_s = if let Some(path_ref) = cwd {
+        cmd = cmd.current_dir(path_ref);
+        format!("CWD:{} ", path_ref)
+    } else {
+        "".to_string()
+    };
+
+    let cmdline = cmd_and_args.join(" ");
+    println!("🏃 {}{}", cwd_s, cmdline);
+    cmd.status()
+        .map_err(|e| anyhow!("could not run '{}{}': {}", cwd_s, cmdline, e))
+        .and_then(|exit_status| {
+            if exit_status.success() {
+                Ok(())
+            } else {
+                Err(anyhow!("'{}' did not finish successfully: {}", cmdline, exit_status))
+            }
+        })
+}
+
+fn run_capturing_stdout(cmd: &mut Command) -> Result<String> {
+    let o = cmd.output()?.stdout;
+    Ok(str::from_utf8(&o)?.to_string())
+}
+
+fn do_test<F>(t: F, context: &str)
+where
+    F: FnOnce() -> Result<()>,
+{
+    match t() {
+        Ok(_) => {}
+        Err(e) => ALL_ERRORS.lock().unwrap().push(format!("{}: {}", context, e)),
+    }
+}
+
+fn rustc_is_nightly() -> bool {
+    // if this crashes the system is not in a good state, so we'll not pretend to be able to recover
+    let out = run_capturing_stdout(Command::new("rustc").args(&["-V"])).unwrap();
+    out.contains("nightly")
+}
+
+fn load_expected_output(name: &str, release_mode: bool) -> Result<String> {
+    let path = Path::new("firmware/qemu/src/bin");
+
+    let filename;
+    if release_mode {
+        filename = format!("{}.release.out", name);
+    } else {
+        filename = format!("{}.out", name);
+    }
+
+    let path = path.join(filename);
+
+    // for error context closure
+    let path_str = path.to_str().unwrap_or("(non-Unicode path)").to_string();
+
+    let content = fs::read_to_string(path)
+        .with_context(|| format!("Failed to load expected output data from {}", path_str))?;
+    Ok(content)
+}
+
+fn test_single_snapshot(name: &str, features: &str, release_mode: bool) -> Result<()> {
+    let display_name = format!("{} ({})", name, if release_mode { "release" } else { "dev" });
+    println!("{}", display_name);
+    let cwd_name = "firmware/qemu";
+    let mut args = if release_mode {
+        vec!["-q", "rrb", name]
+    } else {
+        vec!["-q", "rb", name]
+    };
+    if !features.is_empty() {
+        args.extend_from_slice(&["--features", features]);
+    }
+
+    let actual = run_capturing_stdout(Command::new("cargo").args(&args).current_dir(cwd_name))?;
+
+    let expected = load_expected_output(name, release_mode)?;
+
+    let diff = TextDiff::from_lines(&expected, &actual);
+
+    // if anything isn't ChangeTag::Equal, print it and turn on error flag
+    let mut actual_matches_expected = true;
+    for op in diff.ops() {
+        for change in diff.iter_changes(op) {
+            let styled_change = match change.tag() {
+                ChangeTag::Delete => Some(("-", Style::new().red())),
+                ChangeTag::Insert => Some(("+", Style::new().green())),
+                ChangeTag::Equal => None,
+            };
+            if let Some((sign, style)) = styled_change {
+                actual_matches_expected = false;
+                eprint!("{}{}", style.apply_to(sign).bold(), style.apply_to(change),);
+            }
+        }
+    }
+
+    if actual_matches_expected {
+        Ok(())
+    } else {
+        Err(anyhow!("{}", display_name))
+    }
+}
+
+fn get_installed_targets() -> Result<HashSet<String>> {
+    const INSTALLED_MARKER: &str = " (installed)";
+    let out = run_capturing_stdout(Command::new("rustup").args(&["target", "list"]))?;
+    let mut targets = out.lines().collect::<Vec<_>>();
+    targets.retain(|target| target.contains(INSTALLED_MARKER));
+    let targets: HashSet<String> = targets
+        .iter()
+        .map(|target| target.replace(INSTALLED_MARKER, ""))
+        .collect();
+    Ok(targets)
+}
+
+fn install_targets() -> Result<Vec<String>> {
+    let required_targets = [
+        "thumbv6m-none-eabi",
+        "thumbv7m-none-eabi",
+        "thumbv7em-none-eabi",
+        "thumbv8m.base-none-eabi",
+        "riscv32i-unknown-none-elf",
+    ]
+    .iter()
+    .map(|item| item.to_string())
+    .collect::<HashSet<_>>();
+
+    let installed_targets = get_installed_targets()?;
+    let added_targets = required_targets
+        .difference(&installed_targets)
+        .cloned()
+        .collect::<Vec<_>>();
+
+    if !added_targets.is_empty() {
+        println!("⏳ installing targets");
+
+        let mut args = vec!["target", "add"];
+        args.extend(added_targets.iter().map(|s| s.as_str()));
+        let status = Command::new("rustup").args(&args).status().unwrap();
+        if !status.success() {
+            // since installing targets is the first thing we do, hard panic is OK enough (user would notice at this point)
+            panic!("Error installing targets: {}", added_targets.join(" "));
+        }
+    }
+
+    Ok(added_targets)
+}
+
+fn uninstall_targets(targets: Vec<String>) {
+    if !targets.is_empty() {
+        println!("⏳ uninstalling targets");
+
+        let mut cmd_and_args = vec!["rustup", "target", "remove"];
+        cmd_and_args.extend(targets.iter().map(|s| s.as_str()));
+
+        // only print uninstall errors so the user can fix those manually if needed
+        match run_command(&cmd_and_args, None, &[]) {
+            Ok(_) => {}
+            Err(e) => {
+                eprintln!("Error uninstalling targets {}: {}", targets.join(" "), e);
+            }
+        }
+    }
+}
+
+fn test_book() {
+    println!("🧪 book");
+    do_test(|| run_command(&["cargo", "clean"], None, &[]), "book");
+
+    do_test(
+        || run_command(&["cargo", "build", "--features", "unstable-test"], None, &[]),
+        "book",
+    );
+
+    do_test(
+        || {
+            run_command(
+                &["mdbook", "test", "-L", "../target/debug", "-L", "../target/debug/deps"],
+                Some("book"),
+                &[],
+            )
+        },
+        "book",
+    );
+}
+
+fn test_lint() {
+    println!("🧪 lint");
+    do_test(|| run_command(&["cargo", "clean"], None, &[]), "lint");
+    do_test(
+        || run_command(&["cargo", "fmt", "--all", "--", "--check"], None, &[]),
+        "lint",
+    );
+
+    do_test(|| run_command(&["cargo", "clippy", "--workspace"], None, &[]), "lint");
+}
+
+fn test_host(deny_warnings: bool) {
+    println!("🧪 host");
+
+    let env = if deny_warnings {
+        vec![("RUSTFLAGS", "--deny warnings")]
+    } else {
+        vec![]
+    };
+
+    do_test(|| run_command(&["cargo", "check", "--workspace"], None, &env), "host");
+
+    do_test(
+        || {
+            run_command(
+                &["cargo", "check", "--workspace", "--features", "unstable-test"],
+                None,
+                &env,
+            )
+        },
+        "host",
+    );
+
+    do_test(
+        || run_command(&["cargo", "check", "--workspace", "--features", "alloc"], None, &env),
+        "host",
+    );
+
+    do_test(
+        || {
+            run_command(
+                &["cargo", "test", "--workspace", "--features", "unstable-test"],
+                None,
+                &[],
+            )
+        },
+        "host",
+    );
+
+    do_test(
+        || {
+            run_command(
+                &["cargo", "test", "--workspace", "--features", "unstable-test"],
+                None,
+                &[],
+            )
+        },
+        "host",
+    );
+}
+
+fn test_cross() {
+    println!("🧪 cross");
+    let targets = [
+        "thumbv6m-none-eabi",
+        "thumbv8m.base-none-eabi",
+        "riscv32i-unknown-none-elf",
+    ];
+
+    for target in &targets {
+        do_test(
+            || run_command(&["cargo", "check", "--target", target, "-p", "defmt"], None, &[]),
+            "cross",
+        );
+        do_test(
+            || {
+                run_command(
+                    &[
+                        "cargo",
+                        "check",
+                        "--target",
+                        target,
+                        "-p",
+                        "defmt",
+                        "--features",
+                        "alloc",
+                    ],
+                    None,
+                    &[],
+                )
+            },
+            "cross",
+        );
+    }
+
+    do_test(
+        || {
+            run_command(
+                &[
+                    "cargo",
+                    "check",
+                    "--target",
+                    "thumbv6m-none-eabi",
+                    "--workspace",
+                    "--exclude",
+                    "defmt-itm",
+                    "--exclude",
+                    "firmware",
+                ],
+                Some("firmware"),
+                &[],
+            )
+        },
+        "cross",
+    );
+
+    do_test(
+        || {
+            run_command(
+                &["cargo", "check", "--target", "thumbv7em-none-eabi", "--workspace"],
+                Some("firmware"),
+                &[],
+            )
+        },
+        "cross",
+    );
+
+    do_test(
+        || {
+            run_command(
+                &[
+                    "cargo",
+                    "check",
+                    "--target",
+                    "thumbv6m-none-eabi",
+                    "--features",
+                    "print-defmt",
+                ],
+                Some("firmware/panic-probe"),
+                &[],
+            )
+        },
+        "cross",
+    );
+
+    do_test(
+        || {
+            run_command(
+                &[
+                    "cargo",
+                    "check",
+                    "--target",
+                    "thumbv6m-none-eabi",
+                    "--features",
+                    "print-rtt",
+                ],
+                Some("firmware/panic-probe"),
+                &[],
+            )
+        },
+        "cross",
+    )
+}
+
+fn test_snapshot() {
+    println!("🧪 qemu/snapshot");
+    let mut tests = vec![
+        "log",
+        "timestamp",
+        "panic",
+        "assert",
+        "assert-eq",
+        "assert-ne",
+        "unwrap",
+        "defmt-test",
+        "hints",
+    ];
+
+    if rustc_is_nightly() {
+        tests.push("alloc");
+    }
+
+    let mut features_map = HashMap::new();
+    features_map.insert("alloc", "alloc");
+    let no_features = "";
+
+    for test in tests {
+        let features = features_map.get(test).unwrap_or(&no_features);
+
+        do_test(|| test_single_snapshot(test, features, false), "qemu/snapshot");
+        do_test(|| test_single_snapshot(test, features, true), "qemu/snapshot");
+    }
+}
+
+fn main() -> Result<(), Vec<String>> {
+    let opt: Options = Options::from_args();
+
+    // TODO: one could argue that not all test scenarios require installation of targets
+    let added_targets = install_targets().expect("Error while installing required targets");
+
+    match opt.cmd {
+        TestCommand::TestAll => {
+            test_host(opt.deny_warnings);
+            test_cross();
+            test_snapshot();
+            test_book();
+            test_lint();
+        }
+        TestCommand::TestHost => test_host(opt.deny_warnings),
+        TestCommand::TestCross => test_cross(),
+        TestCommand::TestSnapshot => test_snapshot(),
+        TestCommand::TestBook => test_book(),
+        TestCommand::TestLint => test_lint(),
+    }
+
+    if !opt.keep_targets {
+        uninstall_targets(added_targets);
+    }
+
+    let all_errors = ALL_ERRORS.lock().unwrap();
+    if !all_errors.is_empty() {
+        eprintln!();
+        eprintln!("😔 some tests failed");
+        Err(all_errors.clone())
+    } else {
+        Ok(())
+    }
+}


Test patch:
diff --git a/firmware/qemu/src/bin/defmt-test.rs b/firmware/qemu/src/bin/defmt-test.rs
index ef963e2..7fb5b93 100644
--- a/firmware/qemu/src/bin/defmt-test.rs
+++ b/firmware/qemu/src/bin/defmt-test.rs
@@ -53,7 +53,6 @@ fn panic(_: &core::panic::PanicInfo) -> ! {
     use cortex_m_semihosting::debug;
 
     loop {
-        // NOTE: we return `EXIT_SUCCESS` here, because `test.sh` expects all executables to succeed
-        debug::exit(debug::EXIT_SUCCESS)
+        debug::exit(debug::EXIT_FAILURE)
     }
 }
diff --git a/firmware/qemu/test.sh b/firmware/qemu/test.sh
deleted file mode 100755
index 42cec4e..0000000
--- a/firmware/qemu/test.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/usr/bin/env bash
-
-set -o errexit
-set -o pipefail
-
-function test() {
-    local bin=$1
-    local features=${2-,}
-
-    cargo rb "$bin" --features "$features" | tee "src/bin/$bin.out.new" | diff "src/bin/$bin.out" -
-    cargo rrb "$bin" --features "$features" | tee "src/bin/$bin.release.out.new" | diff "src/bin/$bin.release.out" -
-}
-
-test "log"
-test "timestamp"
-test "panic"
-test "assert"
-test "assert-eq"
-test "assert-ne"
-test "unwrap"
-test "defmt-test"
-test "hints"
-if rustc -V | grep nightly; then
-    test "alloc" "alloc"
-fi