lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit e7eb1e4ad114630fe71de5596c9df2cc6d4e67e3
parent 48f91406bb5c8c00df603e5443af841e08f27c0a
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 05:17:16 +0000

coverage: harden strict coverage and nix validation

- centralize required crate coverage policy and pin the dedicated coverage toolchain
- drive release-preflight from measured gate reports and xtask-owned blocking artifacts
- fix darwin nix shells and strict crate tests needed to keep 100/100/100/100 green
- verify with cargo test -p xtask, cargo run -q -p xtask -- sdk validate, cargo check --workspace, nix run .#release-preflight, and nix flake check

Diffstat:
MCargo.toml | 64++++++++++++++++++++++++++++++++++++----------------------------
Mcontract/coverage/POLICY.md | 3++-
Mcontract/manifest.toml | 8++------
Mcontract/release/publish-set.toml | 4+---
Mcontract/release/runbook.md | 11+++++++----
Mcontract/version.toml | 6+-----
Mcrates/core/Cargo.toml | 9+++++++--
Mcrates/events-codec-wasm/Cargo.toml | 18++++++++++++++----
Mcrates/events-codec/Cargo.toml | 12+++++++++---
Mcrates/events-indexed/Cargo.toml | 9+++++++--
Mcrates/events/Cargo.toml | 9+++++++--
Mcrates/identity/Cargo.toml | 8++++++--
Mcrates/log/Cargo.toml | 9+++++++--
Mcrates/net-core/Cargo.toml | 40++++++++++++++++++++++++++--------------
Mcrates/net/Cargo.toml | 4+++-
Mcrates/nostr-accounts/Cargo.toml | 23+++++++++++++++++++----
Mcrates/nostr-accounts/src/manager.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/nostr-ndb/Cargo.toml | 14+++++++++++---
Mcrates/nostr-runtime/Cargo.toml | 8++++++--
Mcrates/nostr/Cargo.toml | 25+++++++++++++++++--------
Mcrates/replica-db-schema/Cargo.toml | 4+++-
Mcrates/replica-db-wasm/Cargo.toml | 8++++++--
Mcrates/replica-db/Cargo.toml | 4+++-
Mcrates/replica-sync-wasm/Cargo.toml | 12+++++++++---
Mcrates/replica-sync/Cargo.toml | 29++++++++++++++++++++---------
Mcrates/replica-sync/src/emit.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/replica-sync/src/sync_state.rs | 5+----
Mcrates/replica-sync/src/tests.rs | 7+++----
Mcrates/runtime/Cargo.toml | 8+++++---
Mcrates/sql-core/Cargo.toml | 11+++++++++--
Mcrates/sql-wasm-bridge/Cargo.toml | 6++++--
Mcrates/sql-wasm-core/Cargo.toml | 9+++++++--
Mcrates/sql-wasm-core/src/embedded.rs | 7+++++++
Mcrates/trade/Cargo.toml | 20++++++++++++++++----
Mcrates/types/Cargo.toml | 4+++-
Mcrates/xtask/src/contract.rs | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/xtask/src/coverage.rs | 1386++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/xtask/src/export_ts.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/xtask/src/main.rs | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mdocs/nix.md | 4++--
Mflake.nix | 11+++++++++--
Mnix/apps.nix | 33++++++++++++++++++++++-----------
Mnix/common.nix | 299+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mnix/devshells.nix | 8++++++--
Mnix/toolchains.nix | 2+-
Mpublish-crates.sh | 26++++++++++++--------------
Mrust-toolchain-coverage.toml | 10++++++++--
Mscripts/ci/guard_committed_ts_artifacts.sh | 2+-
Mscripts/ci/guard_no_legacy_identifiers.sh | 6+++---
Mscripts/ci/release_preflight.sh | 13+++++++------
Mscripts/ci/release_publish_order.sh | 34+++++++++++++++++-----------------
51 files changed, 2475 insertions(+), 498 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -1,30 +1,30 @@ [workspace] members = [ - "crates/core", - "crates/events", - "crates/events-codec", - "crates/events-codec-wasm", - "crates/events-indexed", - "crates/identity", - "crates/log", - "crates/net", - "crates/net-core", - "crates/nostr", - "crates/nostr-accounts", - "crates/nostr-ndb", - "crates/nostr-runtime", - "crates/runtime", - "crates/sql-wasm-bridge", - "crates/sql-wasm-core", - "crates/sql-core", - "crates/replica-db-schema", - "crates/replica-sync", - "crates/replica-sync-wasm", - "crates/replica-db", - "crates/replica-db-wasm", - "crates/trade", - "crates/types", - "crates/xtask", + "crates/core", + "crates/events", + "crates/events-codec", + "crates/events-codec-wasm", + "crates/events-indexed", + "crates/identity", + "crates/log", + "crates/net", + "crates/net-core", + "crates/nostr", + "crates/nostr-accounts", + "crates/nostr-ndb", + "crates/nostr-runtime", + "crates/runtime", + "crates/sql-wasm-bridge", + "crates/sql-wasm-core", + "crates/sql-core", + "crates/replica-db-schema", + "crates/replica-sync", + "crates/replica-sync-wasm", + "crates/replica-db", + "crates/replica-db-wasm", + "crates/trade", + "crates/types", + "crates/xtask", ] resolver = "2" @@ -72,13 +72,21 @@ directories = { version = "6" } futures = { version = "0.3" } hex = { version = "0.4" } js-sys = { version = "0.3" } -keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "windows-native", "linux-native-sync-persistent", "vendored"] } +keyring = { version = "3.6.3", default-features = false, features = [ + "apple-native", + "windows-native", + "linux-native-sync-persistent", + "vendored", +] } nostr = { version = "0.44.2" } nostr-relay-pool = { version = "0.44.0" } nostr-sdk = { version = "0.44.1" } num_cpus = { version = "1.17.0" } secrecy = { version = "0.10.3" } -serde = { version = "1", default-features = false, features = ["derive"] } +serde = { version = "1", default-features = false, features = [ + "derive", + "alloc", +] } serde_json = { version = "1", default-features = false, features = ["alloc"] } serde-wasm-bindgen = { version = "0.6" } sha2 = { version = "0.10", default-features = false } @@ -96,7 +104,7 @@ tracing-log = { version = "0.2" } tracing-subscriber = { version = "0.3" } ts-rs = { version = "11.1" } typeshare = { version = "1" } -url = { version = "2" } +url = { version = "2" } uuid = { version = "1.22.0", features = ["v4", "v7"] } zeroize = { version = "1" } uniffi = { version = "=0.29.4" } diff --git a/contract/coverage/POLICY.md b/contract/coverage/POLICY.md @@ -24,7 +24,8 @@ All four thresholds are release-blocking. - run coverage checks per crate, not only aggregate workspace totals - a crate cannot be promoted to required unless it is at 100/100/100/100 -- once required, the crate remains blocking on every pull request and push to `master` +- once required, the crate remains blocking on every canonical release-preflight run and any external automation that wraps that run +- `coverage-refresh.tsv` must be generated from measured per-crate gate reports, not from synthetic pass rows ## required crate contract diff --git a/contract/manifest.toml b/contract/manifest.toml @@ -11,12 +11,8 @@ model_crates = [ "radroots-trade", "radroots-identity", ] -algorithm_crates = [ - "radroots-events-codec", -] -wasm_crates = [ - "radroots-events-codec-wasm", -] +algorithm_crates = ["radroots-events-codec"] +wasm_crates = ["radroots-events-codec-wasm"] [surface.internal_replica_crates] schema = "radroots-replica-db-schema" diff --git a/contract/release/publish-set.toml b/contract/release/publish-set.toml @@ -30,9 +30,7 @@ crates = [ ] [internal] -crates = [ - "xtask", -] +crates = ["xtask"] [publish_order] crates = [ diff --git a/contract/release/runbook.md b/contract/release/runbook.md @@ -7,15 +7,18 @@ This runbook applies to the crates listed in `contract/release/publish-set.toml` ## preflight ```bash -./scripts/ci/release_preflight.sh +nix run .#release-preflight ``` This command validates: - sdk contract integrity and release policy parity -- required crate coverage at `100/100/100` +- required crate coverage at `100/100/100/100` - publish crate metadata required for crates.io +The underlying repo-owned entrypoint is `./scripts/ci/release_preflight.sh`. +External release automation should call the canonical local preflight and must not replace it with forge-specific logic. + ## release tag Create an annotated tag whose version matches `release.version` in `contract/release/publish-set.toml`. @@ -29,7 +32,7 @@ git tag -a "v$(awk -F '\"' '/^version = / { print $2; exit }' contract/release/p ## publish simulation ```bash -./publish-crates.sh --dry-run +nix run .#publish-dry-run ``` This runs `cargo publish --dry-run` in release order and reports deferred crates when they depend on earlier crates that are not yet published. @@ -37,7 +40,7 @@ This runs `cargo publish --dry-run` in release order and reports deferred crates ## publish ```bash -./publish-crates.sh --publish +nix run .#publish-crates -- --publish ``` This publishes in `publish_order` and waits for each crate version to become visible on crates.io before continuing. diff --git a/contract/version.toml b/contract/version.toml @@ -20,11 +20,7 @@ minor_on = [ "add_conformance_vector", "add_replica_transport_adapter", ] -patch_on = [ - "fix_docs", - "fix_non_behavioral_codegen", - "fix_packaging_metadata", -] +patch_on = ["fix_docs", "fix_non_behavioral_codegen", "fix_packaging_metadata"] [compatibility] requires_conformance_pass = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-core" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "core value types and unit semantics for radroots sdk contracts" @@ -22,7 +24,10 @@ ts-rs = ["dep:ts-rs"] [dependencies] rust_decimal = { workspace = true, default-features = false } rust_decimal_macros = { workspace = true } -serde = { workspace = true, default-features = false, features = ["alloc", "derive"], optional = true } +serde = { workspace = true, default-features = false, features = [ + "alloc", + "derive", +], optional = true } typeshare = { workspace = true, optional = true } ts-rs = { workspace = true, optional = true } diff --git a/crates/events-codec-wasm/Cargo.toml b/crates/events-codec-wasm/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-events-codec-wasm" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "wasm bindings for radroots event encoding and decoding" @@ -15,11 +17,19 @@ readme.workspace = true crate-type = ["cdylib", "rlib"] [dependencies] -radroots-events = { workspace = true, default-features = false, features = ["std", "serde"] } -radroots-events-codec = { workspace = true, default-features = false, features = ["std", "serde_json"] } +radroots-events = { workspace = true, default-features = false, features = [ + "std", + "serde", +] } +radroots-events-codec = { workspace = true, default-features = false, features = [ + "std", + "serde_json", +] } serde = { workspace = true } serde_json = { workspace = true } wasm-bindgen = { workspace = true } [dev-dependencies] -radroots-core = { workspace = true, default-features = false, features = ["std"] } +radroots-core = { workspace = true, default-features = false, features = [ + "std", +] } diff --git a/crates/events-codec/Cargo.toml b/crates/events-codec/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-events-codec" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "canonical encoders and decoders for radroots nostr event payloads" @@ -21,6 +23,10 @@ nostr = ["dep:nostr", "std"] [dependencies] radroots-core = { workspace = true, default-features = false } radroots-events = { workspace = true, default-features = false } -serde = { workspace = true, default-features = false, features = ["alloc"], optional = true } -serde_json = { workspace = true, default-features = false, features = ["alloc"], optional = true } +serde = { workspace = true, default-features = false, features = [ + "alloc", +], optional = true } +serde_json = { workspace = true, default-features = false, features = [ + "alloc", +], optional = true } nostr = { workspace = true, optional = true } diff --git a/crates/events-indexed/Cargo.toml b/crates/events-indexed/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-events-indexed" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "indexed manifest and checkpoint models for radroots event archives" @@ -18,7 +20,10 @@ typeshare = ["dep:typeshare"] std = [] [dependencies] -serde = { workspace = true, default-features = false, features = ["alloc", "derive"], optional = true } +serde = { workspace = true, default-features = false, features = [ + "alloc", + "derive", +], optional = true } typeshare = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/events/Cargo.toml b/crates/events/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-events" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "nostr event models, kinds, and tag helpers for radroots integrations" @@ -21,7 +23,10 @@ typeshare = ["dep:typeshare", "radroots-core/typeshare"] [dependencies] radroots-core = { workspace = true, default-features = false } -serde = { workspace = true, default-features = false, features = ["alloc", "derive"], optional = true } +serde = { workspace = true, default-features = false, features = [ + "alloc", + "derive", +], optional = true } ts-rs = { workspace = true, optional = true } typeshare = { workspace = true, optional = true } diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-identity" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "identity model and profile utilities for radroots nostr applications" @@ -23,7 +25,9 @@ ts-rs = ["dep:ts-rs"] [dependencies] radroots-runtime = { workspace = true, optional = true } -radroots-events = { workspace = true, optional = true, default-features = false, features = ["serde"] } +radroots-events = { workspace = true, optional = true, default-features = false, features = [ + "serde", +] } nostr = { workspace = true } secrecy = { workspace = true, optional = true } serde = { workspace = true } diff --git a/crates/log/Cargo.toml b/crates/log/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-log" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "tracing-based logging primitives for radroots runtime crates" @@ -18,5 +20,8 @@ std = ["dep:thiserror", "dep:tracing-subscriber", "dep:tracing-appender"] [dependencies] tracing = { workspace = true, default-features = false } thiserror = { workspace = true, optional = true } -tracing-subscriber = { workspace = true, optional = true, features = ["fmt", "env-filter"] } +tracing-subscriber = { workspace = true, optional = true, features = [ + "fmt", + "env-filter", +] } tracing-appender = { workspace = true, optional = true } diff --git a/crates/net-core/Cargo.toml b/crates/net-core/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-net-core" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "network orchestration primitives and runtime interfaces for the radroots sdk" @@ -16,26 +18,36 @@ default = ["std"] std = ["serde/std"] rt = ["std", "dep:tokio"] nostr-client = [ - "std", - "dep:radroots-events", - "dep:radroots-events-codec", - "radroots-events/serde", - "dep:radroots-nostr-accounts", - "dep:secrecy", - "dep:hex", - "dep:tempfile", - "dep:serde_json", - "dep:radroots-nostr" + "std", + "dep:radroots-events", + "dep:radroots-events-codec", + "radroots-events/serde", + "dep:radroots-nostr-accounts", + "dep:secrecy", + "dep:hex", + "dep:tempfile", + "dep:serde_json", + "dep:radroots-nostr", ] directories = ["std", "dep:directories"] fs-persistence = ["std"] [dependencies] -radroots-events = { workspace = true, optional = true, default-features = true, features = ["std", "serde", "typeshare"] } +radroots-events = { workspace = true, optional = true, default-features = true, features = [ + "std", + "serde", + "typeshare", +] } radroots-log = { workspace = true, features = ["std"] } radroots-nostr-accounts = { workspace = true, optional = true, default-features = true } -radroots-events-codec = { workspace = true, optional = true, default-features = true, features = ["std"] } -radroots-nostr = { workspace = true, optional = true, default-features = true, features = ["client", "events", "codec"] } +radroots-events-codec = { workspace = true, optional = true, default-features = true, features = [ + "std", +] } +radroots-nostr = { workspace = true, optional = true, default-features = true, features = [ + "client", + "events", + "codec", +] } directories = { workspace = true, optional = true } hex = { workspace = true, optional = true } secrecy = { workspace = true, optional = true } diff --git a/crates/net/Cargo.toml b/crates/net/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-net" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "unified networking interface for the radroots sdk" diff --git a/crates/nostr-accounts/Cargo.toml b/crates/nostr-accounts/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-nostr-accounts" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "nostr protocol account primitives and vault interfaces for the radroots sdk" @@ -13,7 +15,12 @@ readme.workspace = true [features] default = ["std", "file-store", "memory-vault"] -std = ["dep:serde", "dep:serde_json", "dep:radroots-identity", "dep:radroots-runtime"] +std = [ + "dep:serde", + "dep:serde_json", + "dep:radroots-identity", + "dep:radroots-runtime", +] file-store = ["std"] memory-vault = ["std"] os-keyring = ["std", "dep:keyring"] @@ -21,8 +28,16 @@ ndb-bridge = ["std", "dep:radroots-nostr-ndb"] [dependencies] keyring = { workspace = true, optional = true } -radroots-identity = { workspace = true, optional = true, default-features = false, features = ["std", "profile", "json-file"] } -radroots-nostr-ndb = { workspace = true, optional = true, default-features = false, features = ["ndb", "giftwrap", "rt"] } +radroots-identity = { workspace = true, optional = true, default-features = false, features = [ + "std", + "profile", + "json-file", +] } +radroots-nostr-ndb = { workspace = true, optional = true, default-features = false, features = [ + "ndb", + "giftwrap", + "rt", +] } radroots-runtime = { workspace = true, optional = true } serde = { workspace = true, optional = true, features = ["derive"] } serde_json = { workspace = true, optional = true } diff --git a/crates/nostr-accounts/src/manager.rs b/crates/nostr-accounts/src/manager.rs @@ -495,6 +495,24 @@ mod tests { .join(); } + fn status_kind(status: &RadrootsNostrSelectedAccountStatus) -> &'static str { + match status { + RadrootsNostrSelectedAccountStatus::NotConfigured => "not-configured", + RadrootsNostrSelectedAccountStatus::PublicOnly { .. } => "public-only", + RadrootsNostrSelectedAccountStatus::Ready { .. } => "ready", + } + } + + fn status_account( + status: &RadrootsNostrSelectedAccountStatus, + ) -> Option<&RadrootsNostrAccountRecord> { + match status { + RadrootsNostrSelectedAccountStatus::NotConfigured => None, + RadrootsNostrSelectedAccountStatus::PublicOnly { account } + | RadrootsNostrSelectedAccountStatus::Ready { account } => Some(account), + } + } + #[test] fn manager_persists_selection_and_restores_signing_identity() { let temp = tempfile::tempdir().expect("tempdir"); @@ -549,15 +567,12 @@ mod tests { .expect("signing") .is_none() ); - match manager + let status = manager .selected_account_status() - .expect("selected account status") - { - RadrootsNostrSelectedAccountStatus::PublicOnly { account } => { - assert_eq!(account.label.as_deref(), Some("watch")); - } - other => panic!("unexpected account status: {other:?}"), - } + .expect("selected account status"); + assert_eq!(status_kind(&status), "public-only"); + let account = status_account(&status).expect("account"); + assert_eq!(account.label.as_deref(), Some("watch")); } #[test] @@ -567,16 +582,13 @@ mod tests { .generate_identity(Some("primary".into()), true) .expect("generate"); - match manager + let status = manager .selected_account_status() - .expect("selected account status") - { - RadrootsNostrSelectedAccountStatus::Ready { account } => { - assert_eq!(account.account_id, selected_id); - assert_eq!(account.label.as_deref(), Some("primary")); - } - other => panic!("unexpected account status: {other:?}"), - } + .expect("selected account status"); + assert_eq!(status_kind(&status), "ready"); + let account = status_account(&status).expect("account"); + assert_eq!(account.account_id, selected_id); + assert_eq!(account.label.as_deref(), Some("primary")); } #[test] @@ -675,12 +687,11 @@ mod tests { .expect("selected signing") .is_none() ); - assert!(matches!( - manager - .selected_account_status() - .expect("selected account status"), - RadrootsNostrSelectedAccountStatus::NotConfigured - )); + let status = manager + .selected_account_status() + .expect("selected account status"); + assert_eq!(status_kind(&status), "not-configured"); + assert!(status_account(&status).is_none()); let missing_id = RadrootsIdentity::generate().id(); assert!( @@ -702,15 +713,12 @@ mod tests { .remove_secret(&account_id) .expect("remove secret"); - match manager + let status = manager .selected_account_status() - .expect("selected account status") - { - RadrootsNostrSelectedAccountStatus::PublicOnly { account } => { - assert_eq!(account.account_id, account_id); - } - other => panic!("unexpected account status: {other:?}"), - } + .expect("selected account status"); + assert_eq!(status_kind(&status), "public-only"); + let account = status_account(&status).expect("account"); + assert_eq!(account.account_id, account_id); let wrong_identity = RadrootsIdentity::generate(); manager @@ -725,6 +733,60 @@ mod tests { } #[test] + fn selected_account_status_propagates_store_vault_and_secret_parse_errors() { + let poisoned_manager = RadrootsNostrAccountsManager::new_in_memory(); + poison_manager_state(&poisoned_manager); + let selected_err = poisoned_manager + .selected_account_status() + .expect_err("selected status poisoned"); + assert!(selected_err.to_string().starts_with("store error:")); + + let mut load_error_state = RadrootsNostrAccountStoreState::default(); + let load_error_public = RadrootsIdentity::generate().to_public(); + load_error_state + .accounts + .push(RadrootsNostrAccountRecord::new( + load_error_public.clone(), + Some("watch".into()), + 1, + )); + load_error_state.selected_account_id = Some(load_error_public.id.clone()); + let load_error_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); + load_error_store + .save(&load_error_state) + .expect("save state"); + let vault_load_error_manager = + RadrootsNostrAccountsManager::new(load_error_store, Arc::new(VaultLoadError)) + .expect("manager"); + let vault_load_error = vault_load_error_manager + .selected_account_status() + .expect_err("vault load error"); + assert!(vault_load_error.to_string().starts_with("vault error:")); + + let mut invalid_secret_state = RadrootsNostrAccountStoreState::default(); + let invalid_secret_public = RadrootsIdentity::generate().to_public(); + invalid_secret_state + .accounts + .push(RadrootsNostrAccountRecord::new( + invalid_secret_public.clone(), + Some("invalid".into()), + 1, + )); + invalid_secret_state.selected_account_id = Some(invalid_secret_public.id.clone()); + let invalid_secret_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); + invalid_secret_store + .save(&invalid_secret_state) + .expect("save state"); + let invalid_secret_manager = + RadrootsNostrAccountsManager::new(invalid_secret_store, Arc::new(VaultInvalidSecret)) + .expect("manager"); + let invalid_secret = invalid_secret_manager + .selected_account_status() + .expect_err("invalid secret"); + assert!(invalid_secret.to_string().starts_with("identity error:")); + } + + #[test] fn select_remove_export_and_lookup_paths() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let first_id = manager diff --git a/crates/nostr-ndb/Cargo.toml b/crates/nostr-ndb/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-nostr-ndb" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "nostrdb adapter primitives and runtime interfaces for the radroots sdk" @@ -20,8 +22,14 @@ runtime-adapter = ["std", "dep:radroots-nostr-runtime"] giftwrap = ["std", "ndb"] [dependencies] -radroots-nostr = { workspace = true, default-features = false, features = ["std"] } -radroots-nostr-runtime = { workspace = true, optional = true, default-features = false, features = ["std", "rt", "nostr-client"] } +radroots-nostr = { workspace = true, default-features = false, features = [ + "std", +] } +radroots-nostr-runtime = { workspace = true, optional = true, default-features = false, features = [ + "std", + "rt", + "nostr-client", +] } futures = { workspace = true, optional = true } hex = { workspace = true } nostrdb = { version = "0.9.0", optional = true } diff --git a/crates/nostr-runtime/Cargo.toml b/crates/nostr-runtime/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-nostr-runtime" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "nostr runtime primitives and orchestration interfaces for the radroots sdk" @@ -19,7 +21,9 @@ nostr-client = ["std", "dep:radroots-nostr"] nostr-ndb = ["nostr-client"] [dependencies] -radroots-nostr = { workspace = true, optional = true, default-features = true, features = ["client"] } +radroots-nostr = { workspace = true, optional = true, default-features = true, features = [ + "client", +] } futures = { workspace = true, optional = true } thiserror = { workspace = true } tokio = { workspace = true, optional = true, features = ["rt", "sync", "time"] } diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-nostr" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "nostr protocol primitives and adapter interfaces for the radroots sdk" @@ -15,13 +17,17 @@ readme.workspace = true default = ["std"] std = [] client = ["std", "dep:nostr-sdk", "dep:radroots-identity"] -codec = ["dep:radroots-events", "dep:radroots-events-codec", "radroots-events-codec/nostr"] +codec = [ + "dep:radroots-events", + "dep:radroots-events-codec", + "radroots-events-codec/nostr", +] events = [ - "dep:radroots-events", - "dep:radroots-events-codec", - "radroots-events/std", - "radroots-events/serde", - "radroots-events-codec/std", + "dep:radroots-events", + "dep:radroots-events-codec", + "radroots-events/std", + "radroots-events/serde", + "radroots-events-codec/std", ] http = ["dep:reqwest"] nip17 = ["std", "codec", "nostr/nip44", "nostr/nip59"] @@ -32,7 +38,10 @@ radroots-events-codec = { workspace = true, optional = true, default-features = radroots-identity = { workspace = true, optional = true, default-features = true } nostr = { workspace = true, features = ["nip04"] } nostr-sdk = { workspace = true, optional = true } -reqwest = { workspace = true, optional = true, default-features = false, features = ["json", "rustls-tls"] } +reqwest = { workspace = true, optional = true, default-features = false, features = [ + "json", + "rustls-tls", +] } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/replica-db-schema/Cargo.toml b/crates/replica-db-schema/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-replica-db-schema" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "replica schema models and relational interfaces for radroots data layers" diff --git a/crates/replica-db-wasm/Cargo.toml b/crates/replica-db-wasm/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-replica-db-wasm" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "wasm bindings for replica database runtime interfaces in radroots data layers" @@ -16,7 +18,9 @@ crate-type = ["cdylib", "rlib"] [dependencies] radroots-sql-core = { workspace = true, features = ["bridge"] } -radroots-sql-wasm-core = { workspace = true, default-features = false, features = ["bridge"] } +radroots-sql-wasm-core = { workspace = true, default-features = false, features = [ + "bridge", +] } radroots-replica-db = { workspace = true } radroots-replica-db-schema = { workspace = true } radroots-replica-sync = { workspace = true } diff --git a/crates/replica-db/Cargo.toml b/crates/replica-db/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-replica-db" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "replica sql runtime and migration interfaces for radroots data layers" diff --git a/crates/replica-sync-wasm/Cargo.toml b/crates/replica-sync-wasm/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-replica-sync-wasm" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "wasm bindings for replica synchronization interfaces in radroots data layers" @@ -16,9 +18,13 @@ crate-type = ["cdylib", "rlib"] [dependencies] base64 = { workspace = true } -radroots-events = { workspace = true, default-features = false, features = ["serde"] } +radroots-events = { workspace = true, default-features = false, features = [ + "serde", +] } radroots-sql-core = { workspace = true, features = ["bridge"] } -radroots-sql-wasm-core = { workspace = true, default-features = false, features = ["bridge"] } +radroots-sql-wasm-core = { workspace = true, default-features = false, features = [ + "bridge", +] } radroots-replica-sync = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/replica-sync/Cargo.toml b/crates/replica-sync/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-replica-sync" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "replica event ingest and synchronization interfaces for radroots data layers" @@ -17,22 +19,31 @@ crate-type = ["rlib"] [features] default = ["std"] std = [ - "radroots-events/std", - "radroots-events-codec/std", - "dep:base64", - "dep:uuid", + "radroots-events/std", + "radroots-events-codec/std", + "dep:base64", + "dep:uuid", ] [dependencies] -radroots-events = { workspace = true, default-features = false, features = ["serde"] } -radroots-events-codec = { workspace = true, default-features = false, features = ["serde_json"] } +radroots-events = { workspace = true, default-features = false, features = [ + "serde", +] } +radroots-events-codec = { workspace = true, default-features = false, features = [ + "serde_json", +] } radroots-sql-core = { workspace = true } radroots-replica-db-schema = { workspace = true } radroots-replica-db = { workspace = true } radroots-types = { workspace = true } hex = { workspace = true } -serde = { workspace = true, default-features = false, features = ["alloc", "derive"] } -serde_json = { workspace = true, default-features = false, features = ["alloc"] } +serde = { workspace = true, default-features = false, features = [ + "alloc", + "derive", +] } +serde_json = { workspace = true, default-features = false, features = [ + "alloc", +] } sha2 = { workspace = true, default-features = false } base64 = { workspace = true, optional = true } uuid = { workspace = true, optional = true } diff --git a/crates/replica-sync/src/emit.rs b/crates/replica-sync/src/emit.rs @@ -962,6 +962,34 @@ mod tests { } } + struct DuplicateFarmSelectorExecutor<'a> { + inner: &'a SqliteExecutor, + duplicated_rows_json: String, + } + + impl SqlExecutor for DuplicateFarmSelectorExecutor<'_> { + fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError> { + self.inner.exec(sql, params_json) + } + + fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError> { + let _ = (sql, params_json); + Ok(self.duplicated_rows_json.clone()) + } + + fn begin(&self) -> Result<(), SqlError> { + self.inner.begin() + } + + fn commit(&self) -> Result<(), SqlError> { + self.inner.commit() + } + + fn rollback(&self) -> Result<(), SqlError> { + self.inner.rollback() + } + } + fn seed(exec: &SqliteExecutor) -> (Farm, Plot, Plot) { migrations::run_all_up(exec).expect("migrations"); let farm = farm::create( @@ -2307,6 +2335,35 @@ mod tests { ) .expect("resolve by pair"); assert_eq!(resolved_by_pair.id, farm_row.id); + let duplicate_pair = DuplicateFarmSelectorExecutor { + inner: &exec, + duplicated_rows_json: { + let farm_json = serde_json::to_string(&farm_row).expect("farm json"); + format!("[{farm_json},{farm_json}]") + }, + }; + duplicate_pair.begin().expect("duplicate begin"); + duplicate_pair.rollback().expect("duplicate rollback"); + duplicate_pair.begin().expect("duplicate begin"); + duplicate_pair.commit().expect("duplicate commit"); + duplicate_pair + .exec("CREATE TABLE duplicate_probe (id INTEGER)", "[]") + .expect("duplicate exec"); + let duplicate_err = resolve_farm( + &duplicate_pair, + &RadrootsReplicaFarmSelector { + id: None, + d_tag: Some(farm_row.d_tag.clone()), + pubkey: Some(farm_row.pubkey.clone()), + }, + ) + .map(|_| ()) + .unwrap_err(); + assert!( + duplicate_err + .to_string() + .contains("did not resolve to a single farm") + ); assert!( resolve_farm( &pass, diff --git a/crates/replica-sync/src/sync_state.rs b/crates/replica-sync/src/sync_state.rs @@ -1,8 +1,5 @@ #[cfg(not(feature = "std"))] -use alloc::{ - collections::BTreeMap, - string::{String, ToString}, -}; +use alloc::{collections::BTreeMap, string::String}; #[cfg(feature = "std")] use std::collections::BTreeMap; diff --git a/crates/replica-sync/src/tests.rs b/crates/replica-sync/src/tests.rs @@ -24,10 +24,9 @@ use radroots_types::types::IError; use std::panic; fn unwrap_sql<T>(result: Result<T, IError<SqlError>>, label: &str) -> T { - match result { - Ok(value) => value, - Err(err) => panic!("{label}: {}", err.err), - } + result + .map_err(|err| format!("{label}: {}", err.err)) + .unwrap() } #[test] diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-runtime" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "runtime config, io, and process helpers for radroots services and apps" @@ -21,9 +23,9 @@ clap = { workspace = true, features = ["derive", "env"], optional = true } config = { workspace = true } radroots-log = { workspace = true, features = ["std"] } serde = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } toml = { workspace = true } tracing = { workspace = true } -tempfile = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/sql-core/Cargo.toml b/crates/sql-core/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-sql-core" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "sql execution and migration primitives for radroots data layers" @@ -16,7 +18,12 @@ crate-type = ["rlib"] [features] web = [] -bridge = ["web", "dep:radroots-sql-wasm-bridge", "dep:serde-wasm-bindgen", "dep:wasm-bindgen"] +bridge = [ + "web", + "dep:radroots-sql-wasm-bridge", + "dep:serde-wasm-bindgen", + "dep:wasm-bindgen", +] native = ["dep:rusqlite"] embedded = [] diff --git a/crates/sql-wasm-bridge/Cargo.toml b/crates/sql-wasm-bridge/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-sql-wasm-bridge" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "wasm sql bridge primitives for radroots data layers" @@ -16,4 +18,4 @@ default = [] [dependencies] js-sys = { workspace = true } -wasm-bindgen = { workspace = true } +wasm-bindgen = { workspace = true } diff --git a/crates/sql-wasm-core/Cargo.toml b/crates/sql-wasm-core/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-sql-wasm-core" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "wasm sql runtime primitives for radroots data layers" @@ -22,7 +24,10 @@ embedded = ["dep:rusqlite", "radroots-sql-core/native"] [dependencies] radroots-sql-core = { workspace = true } radroots-sql-wasm-bridge = { workspace = true, optional = true } -rusqlite = { workspace = true, features = ["bundled", "serialize"], optional = true } +rusqlite = { workspace = true, features = [ + "bundled", + "serialize", +], optional = true } chrono = { workspace = true, features = ["serde"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/sql-wasm-core/src/embedded.rs b/crates/sql-wasm-core/src/embedded.rs @@ -308,6 +308,13 @@ mod tests { } #[test] + fn exec_empty_bind_batch_surfaces_invalid_query() { + let engine = EmbeddedSqlEngine::new().unwrap(); + let err = engine.exec("CREATE TABLE broken (", "[]").unwrap_err(); + assert_eq!(err.code(), "ERR_INVALID_QUERY"); + } + + #[test] fn export_bytes_non_empty() { let engine = EmbeddedSqlEngine::new().unwrap(); engine.exec(CREATE_TABLE_SQL, "[]").unwrap(); diff --git a/crates/trade/Cargo.toml b/crates/trade/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-trade" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "trade listing models and tag mappings for radroots nostr flows" @@ -15,7 +17,12 @@ build = "build.rs" [features] default = ["std", "serde", "serde_json", "ts-rs"] std = [] -serde = ["dep:serde", "radroots-core/serde", "radroots-events/serde", "radroots-events-codec/serde"] +serde = [ + "dep:serde", + "radroots-core/serde", + "radroots-events/serde", + "radroots-events-codec/serde", +] serde_json = ["serde", "dep:serde_json"] ts-rs = ["dep:ts-rs", "radroots-events/ts-rs", "radroots-events/std"] @@ -23,6 +30,11 @@ ts-rs = ["dep:ts-rs", "radroots-events/ts-rs", "radroots-events/std"] radroots-core = { workspace = true, default-features = false } radroots-events = { workspace = true, default-features = false } radroots-events-codec = { workspace = true, default-features = false } -serde = { workspace = true, default-features = false, features = ["alloc", "derive"], optional = true } -serde_json = { workspace = true, default-features = false, features = ["alloc"], optional = true } +serde = { workspace = true, default-features = false, features = [ + "alloc", + "derive", +], optional = true } +serde_json = { workspace = true, default-features = false, features = [ + "alloc", +], optional = true } ts-rs = { workspace = true, optional = true } diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml @@ -2,7 +2,9 @@ name = "radroots-types" version = "0.1.0-alpha.1" edition.workspace = true -authors = ["Radroots Authors"] +authors = [ + "Radroots Authors", +] rust-version.workspace = true license.workspace = true description = "shared api result and error wrapper types for radroots sdk surfaces" diff --git a/crates/xtask/src/contract.rs b/crates/xtask/src/contract.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] -use crate::coverage::{read_coverage_policy, CoverageThresholds}; +use crate::coverage::{CoverageThresholds, read_coverage_policy}; use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -223,17 +223,9 @@ fn workspace_package_manifests(workspace_root: &Path) -> Result<BTreeMap<String, Ok(manifests) } -#[cfg_attr(not(test), allow(dead_code))] -fn load_coverage_required(contract_root: &Path) -> Result<CoverageRequiredFile, String> { - let policy = load_coverage_policy(contract_root)?; - Ok(CoverageRequiredFile { - required: CoverageRequiredSection { - crates: policy.required_crates()?, - }, - }) -} - -fn load_coverage_policy(contract_root: &Path) -> Result<crate::coverage::CoveragePolicyFile, String> { +fn load_coverage_policy( + contract_root: &Path, +) -> Result<crate::coverage::CoveragePolicyFile, String> { read_coverage_policy(&contract_root.join("coverage").join("policy.toml")) } @@ -576,7 +568,11 @@ fn validate_coverage_policy_parity( ); } - let required_packages = policy.required_crates()?.into_iter().collect::<BTreeSet<_>>(); + let required_packages = policy + .required_crate_entries() + .iter() + .cloned() + .collect::<BTreeSet<_>>(); if workspace_packages != required_packages { let missing = workspace_packages .difference(&required_packages) @@ -720,11 +716,14 @@ pub fn validate_release_preflight(workspace_root: &Path) -> Result<(), String> { validate_contract_bundle(&bundle)?; let release = load_release_contract(&bundle.root).expect("validated contract includes release metadata"); - let policy = load_coverage_policy(&bundle.root) - .expect("validated contract includes coverage metadata"); + let policy = + load_coverage_policy(&bundle.root).expect("validated contract includes coverage metadata"); let publish_crates = collect_unique_set(&release.publish.crates, "publish.crates") .expect("validated contract enforces unique publish.crates"); - let required_crates = collect_unique_set(&policy.required_crates()?, "required.crates") + let required_crate_list = policy + .required_crates() + .expect("validated contract includes required crates"); + let required_crates = collect_unique_set(&required_crate_list, "required.crates") .expect("validated contract enforces unique required.crates"); validate_publish_package_metadata(workspace_root, &publish_crates)?; validate_required_coverage_summary(workspace_root, &required_crates, policy.thresholds())?; @@ -1154,13 +1153,17 @@ pub enum RadrootsCoreUnitDimension { fn coverage_required_crates_match_policy_required_status() { let root = workspace_root(); let contract_root = root.join("contract"); - let required = load_coverage_required(&contract_root).expect("coverage required"); + let policy = load_coverage_policy(&contract_root).expect("coverage policy"); + let required = CoverageRequiredFile { + required: CoverageRequiredSection { + crates: policy.required_crates().expect("coverage required"), + }, + }; let required_names = required .required .crates .into_iter() .collect::<BTreeSet<_>>(); - let policy = load_coverage_policy(&contract_root).expect("coverage policy"); let policy_required = policy .required_crates() .expect("policy required crates") @@ -1170,6 +1173,27 @@ pub enum RadrootsCoreUnitDimension { } #[test] + fn coverage_policy_required_crates_report_policy_errors() { + let missing_root = temp_root("load_coverage_required_missing_policy"); + let missing_err = + load_coverage_policy(&missing_root).expect_err("missing policy should fail"); + assert!(missing_err.contains("policy.toml")); + let _ = fs::remove_dir_all(&missing_root); + + let duplicate_root = + create_synthetic_workspace("load_coverage_required_duplicate_required"); + let contract_root = duplicate_root.join("contract"); + write_file( + &contract_root.join("coverage").join("policy.toml"), + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots-a\", \"radroots-a\"]\n", + ); + let duplicate_err = + load_coverage_policy(&contract_root).expect_err("duplicate required crates"); + assert!(duplicate_err.contains("duplicate crate")); + let _ = fs::remove_dir_all(&duplicate_root); + } + + #[test] fn package_field_configured_accepts_workspace_table() { let mut package = toml::value::Table::new(); let mut repository = toml::value::Table::new(); @@ -1347,6 +1371,13 @@ members = ["crates/a", "crates/b"] write_file( &coverage_dir.join("coverage-refresh.tsv"), + "crate\tstatus\texec\tfunc\tbranch\tregion\treport\nradroots-a\tpass\t100\t100\t100\t100\tfile\nradroots-a\tpass\t100\t100\t100\t100\tfile\n", + ); + let duplicate_row = load_coverage_refresh_rows(&root).expect_err("duplicate coverage row"); + assert!(duplicate_row.contains("duplicate coverage row")); + + write_file( + &coverage_dir.join("coverage-refresh.tsv"), "crate\tstatus\texec\tfunc\tbranch\tregion\treport\nradroots-a\tfail\t100\t100\t100\t100\tfile\n", ); let required = ["radroots-a".to_string()] @@ -1454,6 +1485,57 @@ crates = ["radroots-a", "radroots-b"] &contract_root.join("coverage").join("policy.toml"), r#"[gate] fail_under_exec_lines = 100.0 +fail_under_functions = 99.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 +require_branches = true + +[required] +crates = ["radroots-a", "radroots-b"] +"#, + ); + let invalid_functions = validate_coverage_policy_parity(&root, &contract_root) + .expect_err("invalid function threshold"); + assert!(invalid_functions.contains("100/100/100/100")); + + write_file( + &contract_root.join("coverage").join("policy.toml"), + r#"[gate] +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 99.0 +fail_under_branches = 100.0 +require_branches = true + +[required] +crates = ["radroots-a", "radroots-b"] +"#, + ); + let invalid_regions = validate_coverage_policy_parity(&root, &contract_root) + .expect_err("invalid region threshold"); + assert!(invalid_regions.contains("100/100/100/100")); + + write_file( + &contract_root.join("coverage").join("policy.toml"), + r#"[gate] +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 99.0 +require_branches = true + +[required] +crates = ["radroots-a", "radroots-b"] +"#, + ); + let invalid_branches = validate_coverage_policy_parity(&root, &contract_root) + .expect_err("invalid branch threshold"); + assert!(invalid_branches.contains("100/100/100/100")); + + write_file( + &contract_root.join("coverage").join("policy.toml"), + r#"[gate] +fail_under_exec_lines = 100.0 fail_under_functions = 100.0 fail_under_regions = 100.0 fail_under_branches = 100.0 @@ -1904,6 +1986,65 @@ edition = "2024" assert!(parse_err.contains("parse")); let _ = fs::remove_dir_all(&invalid); + let contract_manifest_missing = temp_root("parse_contract_manifest_missing"); + let contract_manifest_read_err = + parse_toml::<ContractManifest>(&contract_manifest_missing.join("manifest.toml")) + .expect_err("missing contract manifest"); + assert!(contract_manifest_read_err.contains("read")); + let _ = fs::remove_dir_all(&contract_manifest_missing); + + let contract_manifest_invalid = temp_root("parse_contract_manifest_invalid"); + write_file( + &contract_manifest_invalid.join("manifest.toml"), + "[contract", + ); + let contract_manifest_parse_err = + parse_toml::<ContractManifest>(&contract_manifest_invalid.join("manifest.toml")) + .expect_err("invalid contract manifest"); + assert!(contract_manifest_parse_err.contains("parse")); + let _ = fs::remove_dir_all(&contract_manifest_invalid); + + let version_missing = temp_root("parse_version_policy_missing"); + let version_read_err = parse_toml::<VersionPolicy>(&version_missing.join("version.toml")) + .expect_err("missing version policy"); + assert!(version_read_err.contains("read")); + let _ = fs::remove_dir_all(&version_missing); + + let version_invalid = temp_root("parse_version_policy_invalid"); + write_file(&version_invalid.join("version.toml"), "[version"); + let version_parse_err = parse_toml::<VersionPolicy>(&version_invalid.join("version.toml")) + .expect_err("invalid version policy"); + assert!(version_parse_err.contains("parse")); + let _ = fs::remove_dir_all(&version_invalid); + + let release_missing = temp_root("parse_release_contract_missing"); + let release_read_err = + parse_toml::<ReleaseContractFile>(&release_missing.join("publish-set.toml")) + .expect_err("missing release contract"); + assert!(release_read_err.contains("read")); + let _ = fs::remove_dir_all(&release_missing); + + let release_invalid = temp_root("parse_release_contract_invalid"); + write_file(&release_invalid.join("publish-set.toml"), "[release"); + let release_parse_err = + parse_toml::<ReleaseContractFile>(&release_invalid.join("publish-set.toml")) + .expect_err("invalid release contract"); + assert!(release_parse_err.contains("parse")); + let _ = fs::remove_dir_all(&release_invalid); + + let export_missing = temp_root("parse_export_mapping_missing"); + let export_read_err = parse_toml::<ExportMapping>(&export_missing.join("model.toml")) + .expect_err("missing export mapping"); + assert!(export_read_err.contains("read")); + let _ = fs::remove_dir_all(&export_missing); + + let export_invalid = temp_root("parse_export_mapping_invalid"); + write_file(&export_invalid.join("model.toml"), "[export"); + let export_parse_err = parse_toml::<ExportMapping>(&export_invalid.join("model.toml")) + .expect_err("invalid export mapping"); + assert!(export_parse_err.contains("parse")); + let _ = fs::remove_dir_all(&export_invalid); + let dup = temp_root("publish_flags_duplicate"); write_file( &dup.join("Cargo.toml"), @@ -2371,9 +2512,12 @@ crates = ["radroots-a", "radroots-b"] let required = ["radroots-a".to_string()] .into_iter() .collect::<BTreeSet<_>>(); - let missing_refresh_err = - validate_required_coverage_summary(&missing_refresh_root, &required, strict_thresholds()) - .expect_err("missing refresh should fail"); + let missing_refresh_err = validate_required_coverage_summary( + &missing_refresh_root, + &required, + strict_thresholds(), + ) + .expect_err("missing refresh should fail"); assert!(missing_refresh_err.contains("coverage-refresh.tsv")); let _ = fs::remove_dir_all(&missing_refresh_root); diff --git a/crates/xtask/src/coverage.rs b/crates/xtask/src/coverage.rs @@ -1,5 +1,6 @@ #![forbid(unsafe_code)] +use std::ffi::OsString; use std::fs; use std::path::Path; use std::path::PathBuf; @@ -49,7 +50,7 @@ pub struct CoverageGateResult { pub fail_reasons: Vec<String>, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] struct CoverageGateReport { scope: String, thresholds: CoverageGateReportThresholds, @@ -58,7 +59,7 @@ struct CoverageGateReport { result: CoverageGateReportResult, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] struct CoverageGateReportThresholds { executable_lines: f64, functions: f64, @@ -67,7 +68,7 @@ struct CoverageGateReportThresholds { branches_required: bool, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] struct CoverageGateReportMeasured { executable_lines_percent: f64, executable_lines_source: String, @@ -78,19 +79,19 @@ struct CoverageGateReportMeasured { summary_regions_percent: f64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] struct CoverageGateReportCounts { executable_lines: CoverageCount, branches: CoverageCount, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] struct CoverageCount { covered: u64, total: u64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] struct CoverageGateReportResult { pass: bool, fail_reasons: Vec<String>, @@ -228,7 +229,9 @@ impl CoveragePolicyFile { let mut seen = BTreeSet::new(); for crate_name in &self.required.crates { if crate_name.trim().is_empty() { - return Err("coverage required crates list includes an empty crate name".to_string()); + return Err( + "coverage required crates list includes an empty crate name".to_string() + ); } if !seen.insert(crate_name.clone()) { return Err(format!( @@ -238,6 +241,10 @@ impl CoveragePolicyFile { } Ok(self.required.crates.clone()) } + + pub(crate) fn required_crate_entries(&self) -> &[String] { + &self.required.crates + } } pub(crate) fn coverage_policy_path(root: &Path) -> PathBuf { @@ -286,15 +293,20 @@ fn read_required_crates(path: &Path) -> Result<Vec<String>, String> { } fn read_workspace_crates(workspace_root: &Path) -> Result<Vec<String>, String> { + let packages = read_workspace_packages(workspace_root)?; + Ok(packages.into_iter().map(|(name, _)| name).collect()) +} + +fn read_workspace_packages(workspace_root: &Path) -> Result<Vec<(String, PathBuf)>, String> { let workspace_manifest = parse_toml::<WorkspaceManifest>(&workspace_root.join("Cargo.toml"))?; if workspace_manifest.workspace.members.is_empty() { return Err("workspace members list must not be empty".to_string()); } - let mut names = Vec::with_capacity(workspace_manifest.workspace.members.len()); + let mut packages = Vec::with_capacity(workspace_manifest.workspace.members.len()); let mut seen = BTreeSet::new(); for member in workspace_manifest.workspace.members { let package_manifest = - parse_toml::<PackageManifest>(&workspace_root.join(member).join("Cargo.toml"))?; + parse_toml::<PackageManifest>(&workspace_root.join(&member).join("Cargo.toml"))?; let package_name = package_manifest.package.name; if package_name.trim().is_empty() { return Err("workspace includes an empty package name".to_string()); @@ -305,9 +317,9 @@ fn read_workspace_crates(workspace_root: &Path) -> Result<Vec<String>, String> { package_name )); } - names.push(package_name); + packages.push((package_name, PathBuf::from(member))); } - Ok(names) + Ok(packages) } fn parse_toml<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> { @@ -700,9 +712,43 @@ fn apply_coverage_profile_flags(command: &mut Command, profile: &CoverageProfile } } +fn prepend_toolchain_bin_to_path( + toolchain_bin: &Path, + existing_path: Option<OsString>, +) -> OsString { + match existing_path { + Some(existing) => std::env::join_paths( + std::iter::once(toolchain_bin.to_path_buf()).chain(std::env::split_paths(&existing)), + ) + .expect("joining PATH entries for coverage toolchain should succeed"), + None => OsString::from(toolchain_bin), + } +} + +fn configure_coverage_toolchain_env(command: &mut Command, toolchain_bin: &Path) { + let joined_path = prepend_toolchain_bin_to_path(toolchain_bin, std::env::var_os("PATH")); + command.env("PATH", joined_path); + + for (env_name, binary_name) in [ + ("RUSTC", "rustc"), + ("RUSTDOC", "rustdoc"), + ("LLVM_COV", "llvm-cov"), + ("LLVM_PROFDATA", "llvm-profdata"), + ] { + let binary_path = toolchain_bin.join(binary_name); + if binary_path.exists() { + command.env(env_name, binary_path); + } + } +} + fn coverage_cargo_command_with_override(override_binary: Option<&str>) -> Command { if let Some(binary) = override_binary { - return Command::new(binary); + let mut cmd = Command::new(binary); + if let Some(toolchain_bin) = Path::new(binary).parent() { + configure_coverage_toolchain_env(&mut cmd, toolchain_bin); + } + return cmd; } let mut cmd = Command::new("rustup"); @@ -724,6 +770,55 @@ fn coverage_llvm_cov_command() -> Command { cmd } +const COVERAGE_EXTERNAL_IGNORE_FILENAME_REGEX: &str = + r"(/\.cargo/registry/|/lib/rustlib/src/rust/)"; + +fn escape_regex_literal(raw: &str) -> String { + let mut escaped = String::with_capacity(raw.len()); + for ch in raw.chars() { + match ch { + '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' => { + escaped.push('\\'); + escaped.push(ch); + } + _ => escaped.push(ch), + } + } + escaped +} + +fn coverage_ignore_filename_regex( + workspace_root: &Path, + crate_name: &str, +) -> Result<String, String> { + let mut patterns = vec![COVERAGE_EXTERNAL_IGNORE_FILENAME_REGEX.to_string()]; + let mut found_target = false; + + for (package_name, member_path) in read_workspace_packages(workspace_root)? { + let absolute_member = workspace_root.join(member_path); + if package_name == crate_name { + found_target = true; + continue; + } + patterns.push(format!( + "^{}/", + escape_regex_literal(&absolute_member.display().to_string()) + )); + } + + if !found_target { + return Err(format!( + "workspace coverage filters could not resolve crate directory for {crate_name}" + )); + } + + Ok(format!("({})", patterns.join("|"))) +} + +fn apply_coverage_report_filters(command: &mut Command, ignore_regex: &str) { + command.arg("--ignore-filename-regex").arg(ignore_regex); +} + fn run_crate_with_runner_at_root( args: &[String], workspace_root: &Path, @@ -742,6 +837,7 @@ fn run_crate_with_runner_at_root( let test_threads = parse_optional_u32_arg(args, "test-threads")? .or(profile.test_threads) .unwrap_or(1); + let ignore_regex = coverage_ignore_filename_regex(workspace_root, &crate_name)?; if let Err(err) = fs::create_dir_all(&out_dir) { return Err(format!("failed to create {}: {err}", out_dir.display())); @@ -778,6 +874,7 @@ fn run_crate_with_runner_at_root( { let mut cmd = coverage_llvm_cov_command(); cmd.arg("report").arg("-p").arg(&crate_name); + apply_coverage_report_filters(&mut cmd, &ignore_regex); cmd.arg("--json") .arg("--summary-only") .arg("--branch") @@ -794,6 +891,7 @@ fn run_crate_with_runner_at_root( { let mut cmd = coverage_llvm_cov_command(); cmd.arg("report").arg("-p").arg(&crate_name); + apply_coverage_report_filters(&mut cmd, &ignore_regex); cmd.arg("--lcov") .arg("--branch") .arg("--output-path") @@ -822,7 +920,7 @@ fn run_crate(args: &[String]) -> Result<(), String> { run_crate_with_runner(args, &mut runner) } -fn report_gate(args: &[String]) -> Result<(), String> { +fn report_gate_with_root(args: &[String], root: &Path) -> Result<(), String> { let scope = parse_string_arg(args, "scope")?; let summary_path = PathBuf::from(parse_string_arg(args, "summary")?); let lcov_path = PathBuf::from(parse_string_arg(args, "lcov")?); @@ -844,8 +942,7 @@ fn report_gate(args: &[String]) -> Result<(), String> { .to_string(), ); } - let root = workspace_root(); - read_coverage_policy(&coverage_policy_path(&root))?.thresholds() + read_coverage_policy(&coverage_policy_path(root))?.thresholds() } else { let Some(fail_under_exec_lines) = explicit_exec else { return Err( @@ -917,12 +1014,7 @@ fn report_gate(args: &[String]) -> Result<(), String> { fail_reasons: gate.fail_reasons.clone(), }, }; - - let json = serde_json::to_string_pretty(&report) - .expect("serializing coverage gate report should succeed"); - if let Err(err) = fs::write(&out_path, format!("{json}\n")) { - return Err(format!("failed to write {}: {err}", out_path.display())); - } + write_gate_report(&out_path, &report)?; if lcov.branches_available { eprintln!( @@ -958,29 +1050,101 @@ fn report_gate(args: &[String]) -> Result<(), String> { Ok(()) } +#[cfg_attr(not(test), allow(dead_code))] +fn report_gate(args: &[String]) -> Result<(), String> { + let root = workspace_root(); + report_gate_with_root(args, &root) +} + +fn report_missing_gate_with_root(args: &[String], root: &Path) -> Result<(), String> { + let scope = parse_string_arg(args, "scope")?; + let out_path = PathBuf::from(parse_string_arg(args, "out")?); + let reason = parse_string_arg(args, "reason")?; + let thresholds = read_coverage_policy(&coverage_policy_path(root))?.thresholds(); + + let report = CoverageGateReport { + scope: scope.clone(), + thresholds: CoverageGateReportThresholds { + executable_lines: thresholds.fail_under_exec_lines, + functions: thresholds.fail_under_functions, + regions: thresholds.fail_under_regions, + branches: thresholds.fail_under_branches, + branches_required: thresholds.require_branches, + }, + measured: CoverageGateReportMeasured { + executable_lines_percent: 0.0, + executable_lines_source: executable_source_label(ExecutableSource::Da).to_string(), + functions_percent: 0.0, + branches_percent: None, + branches_available: false, + summary_lines_percent: 0.0, + summary_regions_percent: 0.0, + }, + counts: CoverageGateReportCounts { + executable_lines: CoverageCount { + covered: 0, + total: 0, + }, + branches: CoverageCount { + covered: 0, + total: 0, + }, + }, + result: CoverageGateReportResult { + pass: false, + fail_reasons: vec![reason.clone()], + }, + }; + write_gate_report(&out_path, &report)?; + eprintln!("{scope} gate fail: {reason}"); + Ok(()) +} + +fn write_gate_report(out_path: &Path, report: &CoverageGateReport) -> Result<(), String> { + let json = serde_json::to_string_pretty(report) + .expect("serializing coverage gate report should succeed"); + if let Err(err) = fs::write(out_path, format!("{json}\n")) { + return Err(format!("failed to write {}: {err}", out_path.display())); + } + Ok(()) +} + +fn coverage_report_path(reports_root: &Path, crate_name: &str) -> PathBuf { + reports_root + .join(crate_name.replace('-', "_")) + .join("gate-report.json") +} + +fn read_gate_report(path: &Path) -> Result<CoverageGateReport, String> { + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) => { + return Err(format!( + "failed to read gate report {}: {err}", + path.display() + )); + } + }; + match serde_json::from_str::<CoverageGateReport>(&raw) { + Ok(report) => Ok(report), + Err(err) => Err(format!( + "failed to parse gate report {}: {err}", + path.display() + )), + } +} + fn list_required_crates_with_root(root: &Path, writer: &mut dyn Write) -> Result<(), String> { let required_path = coverage_policy_path(root); let crates = read_required_crates(&required_path)?; write_crate_names_output(writer, crates, "required crates") } -fn list_required_crates() -> Result<(), String> { - let root = workspace_root(); - let mut stdout = std::io::stdout().lock(); - list_required_crates_with_root(&root, &mut stdout) -} - fn list_workspace_crates_with_root(root: &Path, writer: &mut dyn Write) -> Result<(), String> { let crates = read_workspace_crates(&root)?; write_crate_names_output(writer, crates, "workspace crates") } -fn list_workspace_crates() -> Result<(), String> { - let root = workspace_root(); - let mut stdout = std::io::stdout().lock(); - list_workspace_crates_with_root(&root, &mut stdout) -} - fn write_crate_names_output( writer: &mut dyn Write, crates: Vec<String>, @@ -994,24 +1158,99 @@ fn write_crate_names_output( Ok(()) } -pub fn run(args: &[String]) -> Result<(), String> { +fn run_with_root(args: &[String], root: &Path) -> Result<(), String> { match args.first().map(String::as_str) { Some("help") => Ok(()), Some("run-crate") => run_crate(&args[1..]), - Some("report") => report_gate(&args[1..]), - Some("required-crates") => list_required_crates(), - Some("workspace-crates") => list_workspace_crates(), + Some("report") => report_gate_with_root(&args[1..], root), + Some("report-missing") => report_missing_gate_with_root(&args[1..], root), + Some("refresh-summary") => { + let reports_root = match parse_optional_string_arg(&args[1..], "reports-root") { + Some(raw) => PathBuf::from(raw), + None => PathBuf::from("target/coverage"), + }; + let out_path = match parse_optional_string_arg(&args[1..], "out") { + Some(raw) => PathBuf::from(raw), + None => PathBuf::from("target/coverage/coverage-refresh.tsv"), + }; + let status_out_path = match parse_optional_string_arg(&args[1..], "status-out") { + Some(raw) => Some(PathBuf::from(raw)), + None => None, + }; + let required_crates = read_required_crates(&coverage_policy_path(root))?; + + let mut refresh_rows = + String::from("crate\tstatus\texec\tfunc\tbranch\tregion\treport\n"); + let mut status_rows = String::from("crate\tstatus\n"); + + for crate_name in required_crates { + let report_path = coverage_report_path(&reports_root, &crate_name); + let report = read_gate_report(&report_path)?; + let status = if report.result.pass { "pass" } else { "fail" }; + let branch = report.measured.branches_percent.unwrap_or(0.0); + refresh_rows.push_str(&format!( + "{}\t{}\t{:.6}\t{:.6}\t{:.6}\t{:.6}\t{}\n", + crate_name, + status, + report.measured.executable_lines_percent, + report.measured.functions_percent, + branch, + report.measured.summary_regions_percent, + report_path.display() + )); + status_rows.push_str(&format!("{}\t{}\n", crate_name, status)); + } + + if let Some(parent) = out_path.parent() { + if !parent.as_os_str().is_empty() { + if let Err(err) = fs::create_dir_all(parent) { + return Err(format!("failed to create {}: {err}", parent.display())); + } + } + } + fs::write(&out_path, refresh_rows) + .map_err(|err| format!("failed to write {}: {err}", out_path.display()))?; + + if let Some(status_out_path) = status_out_path { + if let Some(parent) = status_out_path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|err| { + format!("failed to create {}: {err}", parent.display()) + })?; + } + } + fs::write(&status_out_path, status_rows).map_err(|err| { + format!("failed to write {}: {err}", status_out_path.display()) + })?; + } + + Ok(()) + } + Some("required-crates") => { + let mut stdout = std::io::stdout().lock(); + list_required_crates_with_root(root, &mut stdout) + } + Some("workspace-crates") => { + let mut stdout = std::io::stdout().lock(); + list_workspace_crates_with_root(root, &mut stdout) + } Some(_) => Err("unknown sdk coverage subcommand".to_string()), None => Err("missing sdk coverage subcommand".to_string()), } } +pub fn run(args: &[String]) -> Result<(), String> { + let root = workspace_root(); + run_with_root(args, &root) +} + #[cfg(test)] mod tests { use super::*; use std::fs; use std::io::{self, Write}; use std::path::Path; + use std::sync::{Mutex, MutexGuard, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_file_path(prefix: &str) -> PathBuf { @@ -1035,6 +1274,37 @@ mod tests { fs::write(path, content).expect("write file"); } + fn cwd_lock() -> &'static Mutex<()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn recover_lock(lock: &'static Mutex<()>) -> MutexGuard<'static, ()> { + match lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } + + fn lock_cwd() -> MutexGuard<'static, ()> { + recover_lock(cwd_lock()) + } + + fn collect_command_envs(cmd: &Command) -> BTreeMap<String, Option<String>> { + let mut envs = BTreeMap::new(); + for (key, value) in cmd.get_envs() { + envs.insert( + key.to_string_lossy().to_string(), + value.map(|raw| raw.to_string_lossy().to_string()), + ); + } + envs + } + + fn ok_runner(_cmd: Command, _name: &str) -> Result<(), String> { + Ok(()) + } + struct FailingWriter; impl Write for FailingWriter { @@ -1088,6 +1358,653 @@ mod tests { } #[test] + fn read_coverage_policy_rejects_non_finite_and_out_of_range_thresholds() { + let non_finite = temp_file_path("coverage_policy_non_finite"); + write_file( + &non_finite, + "[gate]\nfail_under_exec_lines = inf\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots-a\"]\n", + ); + let non_finite_err = + read_coverage_policy(&non_finite).expect_err("non-finite threshold should fail"); + assert!(non_finite_err.contains("must be finite")); + fs::remove_file(non_finite).expect("remove non-finite policy"); + + let out_of_range = temp_file_path("coverage_policy_out_of_range"); + write_file( + &out_of_range, + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 101.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots-a\"]\n", + ); + let out_of_range_err = + read_coverage_policy(&out_of_range).expect_err("out-of-range threshold should fail"); + assert!(out_of_range_err.contains("must be within 0..=100")); + fs::remove_file(out_of_range).expect("remove out-of-range policy"); + } + + #[test] + fn report_missing_gate_uses_policy_thresholds() { + let root = temp_dir_path("report_missing_gate_root"); + let coverage_dir = root.join("contract").join("coverage"); + fs::create_dir_all(&coverage_dir).expect("create coverage dir"); + write_file( + &coverage_dir.join("policy.toml"), + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots-a\"]\n", + ); + let out_path = root.join("gate-report.json"); + + report_missing_gate_with_root( + &[ + "--scope".to_string(), + "radroots-a-blocking".to_string(), + "--out".to_string(), + out_path.display().to_string(), + "--reason".to_string(), + "missing-coverage-artifacts".to_string(), + ], + &root, + ) + .expect("report missing gate"); + + let report_raw = fs::read_to_string(&out_path).expect("read gate report"); + let report_json: serde_json::Value = + serde_json::from_str(&report_raw).expect("parse gate report json"); + assert_eq!( + report_json["thresholds"]["executable_lines"], + serde_json::json!(100.0) + ); + assert_eq!( + report_json["thresholds"]["branches_required"], + serde_json::json!(true) + ); + assert_eq!(report_json["result"]["pass"], serde_json::json!(false)); + assert_eq!( + report_json["result"]["fail_reasons"], + serde_json::json!(["missing-coverage-artifacts"]) + ); + + fs::remove_dir_all(root).expect("remove root"); + } + + #[test] + fn report_missing_gate_reports_argument_policy_and_write_errors() { + let root = temp_dir_path("report_missing_gate_error_root"); + let missing_scope = + report_missing_gate_with_root(&[], &root).expect_err("missing scope should fail"); + assert!(missing_scope.contains("missing --scope")); + + let missing_out = report_missing_gate_with_root( + &[ + "--scope".to_string(), + "radroots-a-blocking".to_string(), + "--reason".to_string(), + "missing-coverage-artifacts".to_string(), + ], + &root, + ) + .expect_err("missing out should fail"); + assert!(missing_out.contains("missing --out")); + + let missing_reason = report_missing_gate_with_root( + &[ + "--scope".to_string(), + "radroots-a-blocking".to_string(), + "--out".to_string(), + root.join("missing-gate.json").display().to_string(), + ], + &root, + ) + .expect_err("missing reason should fail"); + assert!(missing_reason.contains("missing --reason")); + + let policy_err = report_missing_gate_with_root( + &[ + "--scope".to_string(), + "radroots-a-blocking".to_string(), + "--out".to_string(), + root.join("missing-gate.json").display().to_string(), + "--reason".to_string(), + "missing-coverage-artifacts".to_string(), + ], + &root, + ) + .expect_err("missing policy should fail"); + assert!(policy_err.contains("failed to read coverage policy")); + + let coverage_dir = root.join("contract").join("coverage"); + fs::create_dir_all(&coverage_dir).expect("create coverage dir"); + write_file( + &coverage_dir.join("policy.toml"), + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots-a\"]\n", + ); + let out_path = root.join("gate-report.json"); + fs::create_dir_all(&out_path).expect("create blocking output dir"); + let write_err = report_missing_gate_with_root( + &[ + "--scope".to_string(), + "radroots-a-blocking".to_string(), + "--out".to_string(), + out_path.display().to_string(), + "--reason".to_string(), + "missing-coverage-artifacts".to_string(), + ], + &root, + ) + .expect_err("directory output should fail"); + assert!(write_err.contains("failed to write")); + + fs::remove_dir_all(root).expect("remove report missing gate error root"); + } + + #[test] + fn refresh_summary_uses_measured_gate_report_values() { + let root = temp_dir_path("refresh_summary_root"); + let coverage_dir = root.join("contract").join("coverage"); + fs::create_dir_all(&coverage_dir).expect("create coverage dir"); + write_file( + &coverage_dir.join("policy.toml"), + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots-a\"]\n", + ); + + let reports_root = root.join("target").join("coverage"); + let crate_dir = reports_root.join("radroots_a"); + fs::create_dir_all(&crate_dir).expect("create crate dir"); + write_file( + &crate_dir.join("gate-report.json"), + r#"{ + "scope": "radroots-a", + "thresholds": { + "executable_lines": 100.0, + "functions": 100.0, + "regions": 100.0, + "branches": 100.0, + "branches_required": true + }, + "measured": { + "executable_lines_percent": 100.0, + "executable_lines_source": "da", + "functions_percent": 100.0, + "branches_percent": 100.0, + "branches_available": true, + "summary_lines_percent": 100.0, + "summary_regions_percent": 97.5 + }, + "counts": { + "executable_lines": { + "covered": 4, + "total": 4 + }, + "branches": { + "covered": 2, + "total": 2 + } + }, + "result": { + "pass": true, + "fail_reasons": [] + } +}"#, + ); + + let refresh_out = reports_root.join("coverage-refresh.tsv"); + let status_out = reports_root.join("coverage-refresh-status.tsv"); + run_with_root( + &[ + "refresh-summary".to_string(), + "--reports-root".to_string(), + reports_root.display().to_string(), + "--out".to_string(), + refresh_out.display().to_string(), + "--status-out".to_string(), + status_out.display().to_string(), + ], + &root, + ) + .expect("write refresh summary"); + + let refresh = fs::read_to_string(&refresh_out).expect("read refresh summary"); + assert!(refresh.contains("crate\tstatus\texec\tfunc\tbranch\tregion\treport")); + assert!( + refresh.contains("radroots-a\tpass\t100.000000\t100.000000\t100.000000\t97.500000\t") + ); + + let status = fs::read_to_string(&status_out).expect("read status summary"); + assert_eq!(status, "crate\tstatus\nradroots-a\tpass\n"); + + fs::remove_dir_all(root).expect("remove root"); + + let defaults_root = temp_dir_path("refresh_summary_defaults_root"); + let defaults_coverage_dir = defaults_root.join("contract").join("coverage"); + fs::create_dir_all(&defaults_coverage_dir).expect("create defaults coverage dir"); + write_file( + &defaults_coverage_dir.join("policy.toml"), + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots-a\"]\n", + ); + write_file( + &defaults_root + .join("target") + .join("coverage") + .join("radroots_a") + .join("gate-report.json"), + r#"{ + "scope": "radroots-a", + "thresholds": { + "executable_lines": 100.0, + "functions": 100.0, + "regions": 100.0, + "branches": 100.0, + "branches_required": true + }, + "measured": { + "executable_lines_percent": 100.0, + "executable_lines_source": "da", + "functions_percent": 100.0, + "branches_percent": 100.0, + "branches_available": true, + "summary_lines_percent": 100.0, + "summary_regions_percent": 100.0 + }, + "counts": { + "executable_lines": { + "covered": 4, + "total": 4 + }, + "branches": { + "covered": 2, + "total": 2 + } + }, + "result": { + "pass": false, + "fail_reasons": ["synthetic-fail"] + } +}"#, + ); + + let _guard = lock_cwd(); + let previous_dir = std::env::current_dir().expect("read current dir"); + std::env::set_current_dir(&defaults_root).expect("set current dir"); + run_with_root(&["refresh-summary".to_string()], &defaults_root) + .expect("write refresh summary defaults"); + let defaults_refresh = fs::read_to_string( + defaults_root + .join("target") + .join("coverage") + .join("coverage-refresh.tsv"), + ) + .expect("read defaults refresh summary"); + assert!( + defaults_refresh + .contains("radroots-a\tfail\t100.000000\t100.000000\t100.000000\t100.000000\t") + ); + + let dispatch_root = temp_dir_path("refresh_summary_parentless_root"); + let dispatch_coverage_dir = dispatch_root.join("contract").join("coverage"); + fs::create_dir_all(&dispatch_coverage_dir).expect("create dispatch coverage dir"); + write_file( + &dispatch_coverage_dir.join("policy.toml"), + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots-a\"]\n", + ); + write_file( + &dispatch_root.join("Cargo.toml"), + "[workspace]\nmembers = []\nresolver = \"2\"\n", + ); + write_file( + &dispatch_root + .join("target") + .join("coverage") + .join("radroots_a") + .join("gate-report.json"), + r#"{ + "scope": "radroots-a", + "thresholds": { + "executable_lines": 100.0, + "functions": 100.0, + "regions": 100.0, + "branches": 100.0, + "branches_required": true + }, + "measured": { + "executable_lines_percent": 100.0, + "executable_lines_source": "da", + "functions_percent": 100.0, + "branches_percent": 100.0, + "branches_available": true, + "summary_lines_percent": 100.0, + "summary_regions_percent": 100.0 + }, + "counts": { + "executable_lines": { + "covered": 4, + "total": 4 + }, + "branches": { + "covered": 2, + "total": 2 + } + }, + "result": { + "pass": true, + "fail_reasons": [] + } +}"#, + ); + std::env::set_current_dir(&dispatch_root).expect("set dispatch current dir"); + run_with_root( + &[ + "report-missing".to_string(), + "--scope".to_string(), + "radroots-a-blocking".to_string(), + "--out".to_string(), + "missing-gate.json".to_string(), + "--reason".to_string(), + "missing-coverage-artifacts".to_string(), + ], + &dispatch_root, + ) + .expect("dispatch report-missing"); + run_with_root( + &[ + "refresh-summary".to_string(), + "--out".to_string(), + "coverage-refresh.tsv".to_string(), + "--status-out".to_string(), + "coverage-refresh-status.tsv".to_string(), + ], + &dispatch_root, + ) + .expect("dispatch refresh-summary"); + std::env::set_current_dir(previous_dir).expect("restore current dir"); + + assert!(dispatch_root.join("missing-gate.json").exists()); + assert!(dispatch_root.join("coverage-refresh.tsv").exists()); + assert!(dispatch_root.join("coverage-refresh-status.tsv").exists()); + + fs::remove_dir_all(defaults_root).expect("remove defaults root"); + fs::remove_dir_all(dispatch_root).expect("remove dispatch root"); + } + + #[test] + fn refresh_summary_rejects_empty_output_paths() { + let root = temp_dir_path("refresh_summary_empty_paths_root"); + let coverage_dir = root.join("contract").join("coverage"); + fs::create_dir_all(&coverage_dir).expect("create coverage dir"); + write_file( + &coverage_dir.join("policy.toml"), + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots-a\"]\n", + ); + write_file( + &root + .join("target") + .join("coverage") + .join("radroots_a") + .join("gate-report.json"), + r#"{ + "scope": "radroots-a", + "thresholds": { + "executable_lines": 100.0, + "functions": 100.0, + "regions": 100.0, + "branches": 100.0, + "branches_required": true + }, + "measured": { + "executable_lines_percent": 100.0, + "executable_lines_source": "da", + "functions_percent": 100.0, + "branches_percent": 100.0, + "branches_available": true, + "summary_lines_percent": 100.0, + "summary_regions_percent": 100.0 + }, + "counts": { + "executable_lines": { + "covered": 4, + "total": 4 + }, + "branches": { + "covered": 2, + "total": 2 + } + }, + "result": { + "pass": true, + "fail_reasons": [] + } +}"#, + ); + + let out_err = run_with_root( + &[ + "refresh-summary".to_string(), + "--reports-root".to_string(), + root.join("target").join("coverage").display().to_string(), + "--out".to_string(), + String::new(), + ], + &root, + ) + .expect_err("empty out path should fail"); + assert!(out_err.contains("failed to write")); + + let status_err = run_with_root( + &[ + "refresh-summary".to_string(), + "--reports-root".to_string(), + root.join("target").join("coverage").display().to_string(), + "--out".to_string(), + root.join("target") + .join("coverage") + .join("coverage-refresh.tsv") + .display() + .to_string(), + "--status-out".to_string(), + String::new(), + ], + &root, + ) + .expect_err("empty status out path should fail"); + assert!(status_err.contains("failed to write")); + + fs::remove_dir_all(root).expect("remove empty path root"); + } + + #[test] + fn refresh_summary_reports_output_parent_creation_failure() { + let root = temp_dir_path("refresh_summary_out_parent_fail"); + let coverage_dir = root.join("contract").join("coverage"); + fs::create_dir_all(&coverage_dir).expect("create coverage dir"); + write_file( + &coverage_dir.join("policy.toml"), + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots-a\"]\n", + ); + write_file( + &root + .join("target") + .join("coverage") + .join("radroots_a") + .join("gate-report.json"), + r#"{ + "scope": "radroots-a", + "thresholds": { + "executable_lines": 100.0, + "functions": 100.0, + "regions": 100.0, + "branches": 100.0, + "branches_required": true + }, + "measured": { + "executable_lines_percent": 100.0, + "executable_lines_source": "da", + "functions_percent": 100.0, + "branches_percent": 100.0, + "branches_available": true, + "summary_lines_percent": 100.0, + "summary_regions_percent": 100.0 + }, + "counts": { + "executable_lines": { + "covered": 4, + "total": 4 + }, + "branches": { + "covered": 2, + "total": 2 + } + }, + "result": { + "pass": true, + "fail_reasons": [] + } +}"#, + ); + write_file(&root.join("out-blocker"), "x"); + + let err = run_with_root( + &[ + "refresh-summary".to_string(), + "--reports-root".to_string(), + root.join("target").join("coverage").display().to_string(), + "--out".to_string(), + root.join("out-blocker") + .join("nested") + .join("coverage-refresh.tsv") + .display() + .to_string(), + ], + &root, + ) + .expect_err("out parent create failure should bubble up"); + assert!(err.contains("failed to create")); + + fs::remove_dir_all(root).expect("remove out parent fail root"); + } + + #[test] + fn refresh_summary_reports_status_output_parent_creation_failure() { + let root = temp_dir_path("refresh_summary_status_parent_fail"); + let coverage_dir = root.join("contract").join("coverage"); + fs::create_dir_all(&coverage_dir).expect("create coverage dir"); + write_file( + &coverage_dir.join("policy.toml"), + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots-a\"]\n", + ); + write_file( + &root + .join("target") + .join("coverage") + .join("radroots_a") + .join("gate-report.json"), + r#"{ + "scope": "radroots-a", + "thresholds": { + "executable_lines": 100.0, + "functions": 100.0, + "regions": 100.0, + "branches": 100.0, + "branches_required": true + }, + "measured": { + "executable_lines_percent": 100.0, + "executable_lines_source": "da", + "functions_percent": 100.0, + "branches_percent": 100.0, + "branches_available": true, + "summary_lines_percent": 100.0, + "summary_regions_percent": 100.0 + }, + "counts": { + "executable_lines": { + "covered": 4, + "total": 4 + }, + "branches": { + "covered": 2, + "total": 2 + } + }, + "result": { + "pass": true, + "fail_reasons": [] + } +}"#, + ); + write_file(&root.join("status-blocker"), "x"); + + let err = run_with_root( + &[ + "refresh-summary".to_string(), + "--reports-root".to_string(), + root.join("target").join("coverage").display().to_string(), + "--out".to_string(), + root.join("target") + .join("coverage") + .join("coverage-refresh.tsv") + .display() + .to_string(), + "--status-out".to_string(), + root.join("status-blocker") + .join("nested") + .join("coverage-refresh-status.tsv") + .display() + .to_string(), + ], + &root, + ) + .expect_err("status-out parent create failure should bubble up"); + assert!(err.contains("failed to create")); + + fs::remove_dir_all(root).expect("remove status parent fail root"); + } + + #[test] + fn refresh_summary_reports_policy_and_gate_report_errors() { + let root = temp_dir_path("refresh_summary_error_root"); + let policy_err = run_with_root( + &[ + "refresh-summary".to_string(), + "--reports-root".to_string(), + root.join("target").join("coverage").display().to_string(), + ], + &root, + ) + .expect_err("missing policy should fail"); + assert!(policy_err.contains("failed to read coverage policy")); + + let coverage_dir = root.join("contract").join("coverage"); + fs::create_dir_all(&coverage_dir).expect("create coverage dir"); + write_file( + &coverage_dir.join("policy.toml"), + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots-a\"]\n", + ); + let gate_err = run_with_root( + &[ + "refresh-summary".to_string(), + "--reports-root".to_string(), + root.join("target").join("coverage").display().to_string(), + ], + &root, + ) + .expect_err("missing gate report should fail"); + assert!(gate_err.contains("failed to read gate report")); + + fs::remove_dir_all(root).expect("remove refresh summary error root"); + } + + #[test] + fn recover_lock_covers_ok_and_poisoned_paths() { + let ok_lock: &'static Mutex<()> = Box::leak(Box::new(Mutex::new(()))); + let _ok_guard = recover_lock(ok_lock); + + let poisoned_lock: &'static Mutex<()> = Box::leak(Box::new(Mutex::new(()))); + let handle = std::thread::spawn(move || { + let _guard = poisoned_lock.lock().expect("lock poisoned mutex"); + panic!("poison test mutex"); + }); + assert!(handle.join().is_err()); + + let _poisoned_guard = recover_lock(poisoned_lock); + } + + #[test] fn read_summary_reports_empty_data_error() { let path = temp_file_path("summary_empty_data"); write_file(&path, r#"{"data":[]}"#); @@ -1263,8 +2180,8 @@ test_threads = 4 "#, ) .expect("write profiles"); - let profile = read_coverage_profile(&root, "radroots-log") - .expect("valid positive thread profile"); + let profile = + read_coverage_profile(&root, "radroots-log").expect("valid positive thread profile"); assert_eq!(profile.test_threads, Some(4)); fs::remove_dir_all(root).expect("remove root"); } @@ -1473,6 +2390,42 @@ test_threads = 0 parse_toml::<CoveragePolicyFile>(&invalid).expect_err("invalid toml should fail"); assert!(parse_err.contains("failed to parse")); fs::remove_file(invalid).expect("remove invalid toml"); + + let workspace_missing = temp_file_path("parse_toml_workspace_missing"); + let workspace_read_err = parse_toml::<WorkspaceManifest>(&workspace_missing) + .expect_err("missing workspace manifest should fail"); + assert!(workspace_read_err.contains("failed to read")); + + let workspace_invalid = temp_file_path("parse_toml_workspace_invalid"); + write_file(&workspace_invalid, "[workspace"); + let workspace_parse_err = parse_toml::<WorkspaceManifest>(&workspace_invalid) + .expect_err("invalid workspace manifest should fail"); + assert!(workspace_parse_err.contains("failed to parse")); + fs::remove_file(workspace_invalid).expect("remove invalid workspace manifest"); + + let package_missing = temp_file_path("parse_toml_package_missing"); + let package_read_err = parse_toml::<PackageManifest>(&package_missing) + .expect_err("missing package manifest should fail"); + assert!(package_read_err.contains("failed to read")); + + let package_invalid = temp_file_path("parse_toml_package_invalid"); + write_file(&package_invalid, "[package"); + let package_parse_err = parse_toml::<PackageManifest>(&package_invalid) + .expect_err("invalid package manifest should fail"); + assert!(package_parse_err.contains("failed to parse")); + fs::remove_file(package_invalid).expect("remove invalid package manifest"); + + let profiles_missing = temp_file_path("parse_toml_profiles_missing"); + let profiles_read_err = parse_toml::<CoverageProfilesFile>(&profiles_missing) + .expect_err("missing coverage profiles should fail"); + assert!(profiles_read_err.contains("failed to read")); + + let profiles_invalid = temp_file_path("parse_toml_profiles_invalid"); + write_file(&profiles_invalid, "[profiles.default"); + let profiles_parse_err = parse_toml::<CoverageProfilesFile>(&profiles_invalid) + .expect_err("invalid coverage profiles should fail"); + assert!(profiles_parse_err.contains("failed to parse")); + fs::remove_file(profiles_invalid).expect("remove invalid coverage profiles"); } #[test] @@ -1654,6 +2607,7 @@ test_threads = 0 "3".to_string(), ]; let mut names = Vec::new(); + let mut rendered_commands = Vec::new(); let mut runner = |cmd: Command, name: &str| { names.push(name.to_string()); let rendered = cmd @@ -1662,6 +2616,7 @@ test_threads = 0 .collect::<Vec<_>>() .join(" "); assert!(!rendered.is_empty()); + rendered_commands.push(rendered); Ok(()) }; run_crate_with_runner(&args, &mut runner).expect("run crate with stub runner"); @@ -1674,16 +2629,44 @@ test_threads = 0 "cargo llvm-cov report --lcov".to_string(), ] ); + assert!( + rendered_commands + .iter() + .filter(|rendered| rendered.contains("report -p radroots-core")) + .all(|rendered| rendered.contains("--ignore-filename-regex")) + ); + assert!( + rendered_commands + .iter() + .filter(|rendered| rendered.contains("report -p radroots-core")) + .all(|rendered| rendered.contains(COVERAGE_EXTERNAL_IGNORE_FILENAME_REGEX)) + ); fs::remove_dir_all(out).expect("remove run crate output dir"); } #[test] + fn coverage_ignore_filename_regex_excludes_external_and_sibling_workspace_paths() { + let root = workspace_root(); + let ignore_regex = + coverage_ignore_filename_regex(&root, "radroots-core").expect("build ignore regex"); + assert!(ignore_regex.contains(COVERAGE_EXTERNAL_IGNORE_FILENAME_REGEX)); + assert!(ignore_regex.contains("crates/identity")); + assert!(!ignore_regex.contains("crates/core/")); + } + + #[test] + fn escape_regex_literal_escapes_regex_metacharacters() { + let escaped = escape_regex_literal(r"\.+*?()|[]{}^$"); + assert_eq!(escaped, r"\\\.\+\*\?\(\)\|\[\]\{\}\^\$"); + } + + #[test] fn coverage_cargo_command_defaults_to_rustup_nightly() { let cmd = coverage_cargo_command_with_override(None); - let args = cmd - .get_args() - .map(|arg| arg.to_string_lossy().to_string()) - .collect::<Vec<_>>(); + let mut args = Vec::new(); + for arg in cmd.get_args() { + args.push(arg.to_string_lossy().to_string()); + } assert_eq!(cmd.get_program().to_string_lossy(), "rustup"); assert_eq!( @@ -1697,15 +2680,89 @@ test_threads = 0 } #[test] - fn coverage_cargo_command_uses_override_binary_when_present() { - let cmd = coverage_cargo_command_with_override(Some("/tmp/nightly-cargo")); - let args = cmd - .get_args() - .map(|arg| arg.to_string_lossy().to_string()) - .collect::<Vec<_>>(); + fn coverage_cargo_command_override_variants_cover_parented_and_parentless_paths() { + let toolchain_dir = temp_dir_path("coverage_toolchain_override"); + fs::create_dir_all(&toolchain_dir).expect("create toolchain dir"); + for binary in [ + "nightly-cargo", + "rustc", + "rustdoc", + "llvm-cov", + "llvm-profdata", + ] { + write_file(&toolchain_dir.join(binary), ""); + } - assert_eq!(cmd.get_program().to_string_lossy(), "/tmp/nightly-cargo"); - assert!(args.is_empty()); + let default_cmd = coverage_cargo_command_with_override(None); + let mut args = Vec::new(); + for arg in default_cmd.get_args() { + args.push(arg.to_string_lossy().to_string()); + } + assert_eq!(default_cmd.get_program().to_string_lossy(), "rustup"); + assert_eq!( + args, + vec![ + "run".to_string(), + "nightly".to_string(), + "cargo".to_string() + ] + ); + + let override_binary = toolchain_dir.join("nightly-cargo"); + let cmd = coverage_cargo_command_with_override(Some( + override_binary + .to_str() + .expect("override path should be utf-8"), + )); + + assert_eq!( + cmd.get_program().to_string_lossy(), + override_binary.to_string_lossy() + ); + assert!(cmd.get_args().next().is_none()); + let mut envs = collect_command_envs(&cmd); + envs.insert("MISSING".to_string(), None); + assert_eq!( + envs.get("RUSTC"), + Some(&Some( + toolchain_dir.join("rustc").to_string_lossy().to_string() + )) + ); + assert_eq!( + envs.get("RUSTDOC"), + Some(&Some( + toolchain_dir.join("rustdoc").to_string_lossy().to_string() + )) + ); + assert_eq!( + envs.get("LLVM_COV"), + Some(&Some( + toolchain_dir.join("llvm-cov").to_string_lossy().to_string() + )) + ); + assert_eq!( + envs.get("LLVM_PROFDATA"), + Some(&Some( + toolchain_dir + .join("llvm-profdata") + .to_string_lossy() + .to_string() + )) + ); + let path_env = envs + .get("PATH") + .and_then(|value| value.as_ref()) + .expect("override binary should prepend PATH"); + assert!(path_env.starts_with(toolchain_dir.to_string_lossy().as_ref())); + let mut cmd = coverage_cargo_command_with_override(Some("/")); + cmd.env_remove("RUSTC"); + cmd.env_remove("LLVM_COV"); + assert_eq!(cmd.get_program().to_string_lossy(), "/"); + let envs = collect_command_envs(&cmd); + assert_eq!(envs.get("RUSTC"), Some(&None)); + assert_eq!(envs.get("LLVM_COV"), Some(&None)); + + fs::remove_dir_all(toolchain_dir).expect("remove toolchain dir"); } #[test] @@ -1715,6 +2772,38 @@ test_threads = 0 let fallback = workspace_root_with_override(Some("")); assert!(fallback.join("Cargo.toml").exists()); + + let default_root = workspace_root_with_override(None); + assert!(default_root.join("Cargo.toml").exists()); + } + + #[test] + fn prepend_toolchain_bin_to_path_covers_missing_and_existing_path_inputs() { + let toolchain_dir = PathBuf::from("/tmp/radroots-coverage-toolchain"); + let no_path = prepend_toolchain_bin_to_path(&toolchain_dir, None); + assert_eq!(no_path, OsString::from(&toolchain_dir)); + + let joined = + prepend_toolchain_bin_to_path(&toolchain_dir, Some(OsString::from("/usr/bin:/bin"))); + let joined = joined.to_string_lossy().to_string(); + assert!(joined.starts_with("/tmp/radroots-coverage-toolchain")); + assert!(joined.contains("/usr/bin")); + } + + #[test] + fn collect_command_envs_cover_helper_paths() { + let mut cmd = Command::new("sh"); + cmd.env("PRESENT", "value"); + cmd.env_remove("REMOVED"); + let envs = collect_command_envs(&cmd); + assert_eq!(envs.get("PRESENT"), Some(&Some("value".to_string()))); + assert_eq!(envs.get("REMOVED"), Some(&None)); + } + + #[test] + fn ok_runner_helper_returns_success() { + let cmd = Command::new("true"); + assert!(ok_runner(cmd, "noop").is_ok()); } #[test] @@ -1778,7 +2867,19 @@ test_threads = 0 #[test] fn run_crate_with_runner_at_root_covers_profile_and_runner_error_paths() { + let write_minimal_workspace = |root: &Path| { + write_file( + &root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/core\"]\n", + ); + write_file( + &root.join("crates").join("core").join("Cargo.toml"), + "[package]\nname = \"radroots-core\"\nversion = \"0.1.0-alpha.1\"\nedition = \"2024\"\n", + ); + }; + let profile_root = temp_dir_path("run_crate_profile_invalid"); + write_minimal_workspace(&profile_root); write_file( &profile_root .join("contract") @@ -1800,6 +2901,7 @@ test_threads = 0 let thread_root = temp_dir_path("run_crate_bad_threads"); fs::create_dir_all(&thread_root).expect("create thread root"); + write_minimal_workspace(&thread_root); let thread_args = vec![ "--crate".to_string(), "radroots-core".to_string(), @@ -1816,6 +2918,7 @@ test_threads = 0 for fail_step in [2usize, 3usize, 4usize] { let step_root = temp_dir_path("run_crate_step_fail"); + write_minimal_workspace(&step_root); let step_args = vec![ "--crate".to_string(), "radroots-core".to_string(), @@ -2109,6 +3212,57 @@ test_threads = 0 .expect_err("missing thresholds"); assert!(missing_thresholds.contains("missing coverage thresholds")); + let missing_functions = report_gate(&[ + "--scope".to_string(), + "crate".to_string(), + "--summary".to_string(), + summary_path.display().to_string(), + "--lcov".to_string(), + lcov_path.display().to_string(), + "--out".to_string(), + out_path.display().to_string(), + "--fail-under-exec-lines".to_string(), + "100".to_string(), + ]) + .expect_err("missing functions threshold"); + assert!(missing_functions.contains("missing coverage thresholds")); + + let missing_regions = report_gate(&[ + "--scope".to_string(), + "crate".to_string(), + "--summary".to_string(), + summary_path.display().to_string(), + "--lcov".to_string(), + lcov_path.display().to_string(), + "--out".to_string(), + out_path.display().to_string(), + "--fail-under-exec-lines".to_string(), + "100".to_string(), + "--fail-under-functions".to_string(), + "100".to_string(), + ]) + .expect_err("missing regions threshold"); + assert!(missing_regions.contains("missing coverage thresholds")); + + let missing_branches = report_gate(&[ + "--scope".to_string(), + "crate".to_string(), + "--summary".to_string(), + summary_path.display().to_string(), + "--lcov".to_string(), + lcov_path.display().to_string(), + "--out".to_string(), + out_path.display().to_string(), + "--fail-under-exec-lines".to_string(), + "100".to_string(), + "--fail-under-functions".to_string(), + "100".to_string(), + "--fail-under-regions".to_string(), + "100".to_string(), + ]) + .expect_err("missing branches threshold"); + assert!(missing_branches.contains("missing coverage thresholds")); + let missing_summary_file = report_gate(&[ "--scope".to_string(), "crate".to_string(), @@ -2123,6 +3277,14 @@ test_threads = 0 .expect_err("missing summary file should fail"); assert!(missing_summary_file.contains("failed to read summary")); + let missing_gate_report = read_gate_report(&root.join("missing-gate-report.json")) + .expect_err("missing gate report should fail"); + assert!(missing_gate_report.contains("failed to read gate report")); + + write_file(&out_path, "{not-json"); + let invalid_gate_report = read_gate_report(&out_path).expect_err("invalid gate report"); + assert!(invalid_gate_report.contains("failed to parse gate report")); + let missing_lcov_file = report_gate(&[ "--scope".to_string(), "crate".to_string(), @@ -2153,10 +3315,100 @@ test_threads = 0 .expect_err("policy gate mixed with explicit thresholds"); assert!(mixed_policy_gate.contains("cannot be combined")); + let mixed_policy_gate_regions = report_gate(&[ + "--scope".to_string(), + "crate".to_string(), + "--summary".to_string(), + summary_path.display().to_string(), + "--lcov".to_string(), + lcov_path.display().to_string(), + "--out".to_string(), + out_path.display().to_string(), + "--policy-gate".to_string(), + "--fail-under-regions".to_string(), + "100.0".to_string(), + ]) + .expect_err("policy gate mixed with regions threshold"); + assert!(mixed_policy_gate_regions.contains("cannot be combined")); + + let mixed_policy_gate_branches_flag = report_gate(&[ + "--scope".to_string(), + "crate".to_string(), + "--summary".to_string(), + summary_path.display().to_string(), + "--lcov".to_string(), + lcov_path.display().to_string(), + "--out".to_string(), + out_path.display().to_string(), + "--policy-gate".to_string(), + "--require-branches".to_string(), + ]) + .expect_err("policy gate mixed with require-branches"); + assert!(mixed_policy_gate_branches_flag.contains("cannot be combined")); + fs::remove_dir_all(root).expect("remove report arg errors root"); } #[test] + fn coverage_ignore_filename_regex_reports_unknown_crate() { + let root = temp_dir_path("coverage_unknown_crate_root"); + write_file( + &root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/core\"]\n", + ); + write_file( + &root.join("crates").join("core").join("Cargo.toml"), + "[package]\nname = \"radroots-core\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ); + + let err = coverage_ignore_filename_regex(&root, "radroots-missing") + .expect_err("unknown crate should fail"); + assert!(err.contains("could not resolve crate directory")); + + fs::remove_dir_all(root).expect("remove unknown crate root"); + } + + #[test] + fn coverage_ignore_filename_regex_reports_workspace_manifest_errors() { + let root = temp_dir_path("coverage_regex_workspace_error_root"); + let read_err = coverage_ignore_filename_regex(&root, "radroots-core") + .expect_err("missing workspace manifest should fail"); + assert!(read_err.contains("failed to read")); + + write_file(&root.join("Cargo.toml"), "[workspace"); + let parse_err = coverage_ignore_filename_regex(&root, "radroots-core") + .expect_err("invalid workspace manifest should fail"); + assert!(parse_err.contains("failed to parse")); + + fs::remove_dir_all(root).expect("remove workspace error root"); + } + + #[test] + fn run_crate_with_runner_at_root_reports_ignore_filter_errors() { + let root = temp_dir_path("run_crate_ignore_filter_error"); + write_file( + &root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/other\"]\n", + ); + write_file( + &root.join("crates").join("other").join("Cargo.toml"), + "[package]\nname = \"radroots-other\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ); + let args = vec![ + "--crate".to_string(), + "radroots-core".to_string(), + "--out".to_string(), + root.join("target").join("coverage").display().to_string(), + ]; + let mut runner = ok_runner; + let err = run_crate_with_runner_at_root(&args, &root, &mut runner) + .expect_err("missing crate coverage filter should fail"); + assert!(err.contains("could not resolve crate directory")); + + fs::remove_dir_all(root).expect("remove run crate ignore filter root"); + } + + #[test] fn run_dispatches_subcommands_and_errors() { run(&["help".to_string()]).expect("help subcommand"); run(&["required-crates".to_string()]).expect("required crates subcommand"); @@ -2237,4 +3489,36 @@ test_threads = 0 assert!(out_path.exists()); fs::remove_dir_all(root).expect("remove report dispatch root"); } + + #[test] + fn report_gate_with_root_reports_policy_read_errors() { + let root = temp_dir_path("report_gate_policy_root_error"); + let summary_path = root.join("summary.json"); + let lcov_path = root.join("coverage.info"); + let out_path = root.join("gate-report.json"); + write_file( + &summary_path, + r#"{"data":[{"totals":{"functions":{"percent":100.0},"lines":{"percent":100.0},"regions":{"percent":100.0}}}]}"#, + ); + write_file(&lcov_path, "DA:1,1\nBRDA:1,0,0,1\n"); + + let err = report_gate_with_root( + &[ + "--scope".to_string(), + "crate-x".to_string(), + "--summary".to_string(), + summary_path.display().to_string(), + "--lcov".to_string(), + lcov_path.display().to_string(), + "--out".to_string(), + out_path.display().to_string(), + "--policy-gate".to_string(), + ], + &root, + ) + .expect_err("missing policy should fail"); + assert!(err.contains("failed to read coverage policy")); + + fs::remove_dir_all(root).expect("remove report gate policy error root"); + } } diff --git a/crates/xtask/src/export_ts.rs b/crates/xtask/src/export_ts.rs @@ -629,10 +629,7 @@ manifest_file = "export-manifest.json" "#, ); write_file( - &root - .join("contract") - .join("coverage") - .join("policy.toml"), + &root.join("contract").join("coverage").join("policy.toml"), r#"[gate] fail_under_exec_lines = 100.0 fail_under_functions = 100.0 @@ -1104,6 +1101,64 @@ mod tests { } #[test] + fn export_ts_wasm_artifacts_returns_ok_when_no_wasm_packages_are_selected() { + let root = create_synthetic_workspace("export_ts_no_wasm_packages", false); + let out_dir = root.join("out"); + fs::create_dir_all(&out_dir).expect("create output dir"); + + export_ts_wasm_artifacts(&root, &out_dir).expect("export wasm without wasm packages"); + assert!(!out_dir.join("ts").join("packages").exists()); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn export_ts_wasm_artifacts_copies_selected_wasm_package_dist() { + let root = create_synthetic_workspace("export_ts_with_wasm_dist", false); + write_file( + &root.join("contract").join("exports").join("ts.toml"), + r#"[language] +id = "ts" +repository = "sdk-typescript" + +[packages] +"radroots-a" = "@radroots/a" +"radroots-a-wasm" = "@radroots/a-wasm" + +[artifacts] +models_dir = "src/generated" +constants_dir = "src/generated" +wasm_dist_dir = "dist" +manifest_file = "export-manifest.json" +"#, + ); + write_file( + &root + .join("crates") + .join("a-wasm") + .join("pkg") + .join("dist") + .join("radroots-a-wasm.js"), + "export const probe = true;\n", + ); + let out_dir = root.join("out"); + fs::create_dir_all(&out_dir).expect("create output dir"); + + export_ts_wasm_artifacts(&root, &out_dir).expect("export wasm dist"); + assert!( + out_dir + .join("ts") + .join("packages") + .join("a-wasm") + .join("dist") + .join("radroots-a-wasm.js") + .exists() + ); + + fs::remove_dir_all(root).expect("remove root"); + } + + #[test] fn crate_supports_ts_rs_reflects_manifest_presence() { let root = unique_temp_dir("crate_supports_ts_rs"); let crate_dir = root.join("crates").join("probe"); diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs @@ -24,6 +24,12 @@ fn usage() { eprintln!( " cargo xtask sdk coverage report --scope <scope> --summary <file> --lcov <file> --out <file> [--policy-gate | (--fail-under-exec-lines <pct> --fail-under-functions <pct> --fail-under-regions <pct> --fail-under-branches <pct> [--require-branches])]" ); + eprintln!( + " cargo xtask sdk coverage report-missing --scope <scope> --out <file> --reason <reason>" + ); + eprintln!( + " cargo xtask sdk coverage refresh-summary [--reports-root <dir>] [--out <file>] [--status-out <file>]" + ); } fn workspace_root_with_override(override_root: Option<&str>) -> PathBuf { @@ -89,40 +95,64 @@ fn parse_crate_out_dir( Ok((crate_selector, out_dir)) } +fn export_ts_models_with_root(args: &[String], root: &Path) -> Result<(), String> { + let out_dir = parse_out_dir(args, root)?; + export_ts::export_ts_models(root, &out_dir) +} + fn export_ts_models(args: &[String]) -> Result<(), String> { let root = workspace_root(); - let out_dir = parse_out_dir(args, &root)?; - export_ts::export_ts_models(&root, &out_dir) + export_ts_models_with_root(args, &root) +} + +fn export_ts_constants_with_root(args: &[String], root: &Path) -> Result<(), String> { + let out_dir = parse_out_dir(args, root)?; + export_ts::export_ts_constants(root, &out_dir) } fn export_ts_constants(args: &[String]) -> Result<(), String> { let root = workspace_root(); + export_ts_constants_with_root(args, &root) +} + +fn export_ts_wasm_with_root(args: &[String], root: &Path) -> Result<(), String> { let out_dir = parse_out_dir(args, &root)?; - export_ts::export_ts_constants(&root, &out_dir) + export_ts::export_ts_wasm_artifacts(root, &out_dir) } fn export_ts_wasm(args: &[String]) -> Result<(), String> { let root = workspace_root(); - let out_dir = parse_out_dir(args, &root)?; - export_ts::export_ts_wasm_artifacts(&root, &out_dir) + export_ts_wasm_with_root(args, &root) +} + +fn export_manifest_with_root(args: &[String], root: &Path) -> Result<(), String> { + let out_dir = parse_out_dir(args, root)?; + export_ts::write_ts_export_manifest(root, &out_dir).map(|_| ()) } fn export_manifest(args: &[String]) -> Result<(), String> { let root = workspace_root(); - let out_dir = parse_out_dir(args, &root)?; - export_ts::write_ts_export_manifest(&root, &out_dir).map(|_| ()) + export_manifest_with_root(args, &root) +} + +fn export_ts_with_root(args: &[String], root: &Path) -> Result<(), String> { + let out_dir = parse_out_dir(args, root)?; + export_ts::export_ts_bundle(root, &out_dir).map(|_| ()) } fn export_ts(args: &[String]) -> Result<(), String> { let root = workspace_root(); - let out_dir = parse_out_dir(args, &root)?; - export_ts::export_ts_bundle(&root, &out_dir).map(|_| ()) + export_ts_with_root(args, &root) +} + +fn export_ts_crate_with_root(args: &[String], root: &Path) -> Result<(), String> { + let (crate_selector, out_dir) = parse_crate_out_dir(args, root)?; + export_ts::export_ts_bundle_for_crate(root, &out_dir, &crate_selector).map(|_| ()) } fn export_ts_crate(args: &[String]) -> Result<(), String> { let root = workspace_root(); - let (crate_selector, out_dir) = parse_crate_out_dir(args, &root)?; - export_ts::export_ts_bundle_for_crate(&root, &out_dir, &crate_selector).map(|_| ()) + export_ts_crate_with_root(args, &root) } fn validate_contract() -> Result<(), String> { @@ -187,7 +217,7 @@ fn main() -> ExitCode { mod tests { use super::*; use std::fs; - use std::sync::{Mutex, OnceLock}; + use std::sync::{Mutex, MutexGuard, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; fn workspace_lock() -> &'static Mutex<()> { @@ -195,6 +225,13 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } + fn lock_workspace() -> MutexGuard<'static, ()> { + match workspace_lock().lock() { + Ok(guard) => guard, + Err(poison) => poison.into_inner(), + } + } + fn unique_temp_dir(prefix: &str) -> PathBuf { let ns = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -203,6 +240,168 @@ mod tests { std::env::temp_dir().join(format!("radroots_xtask_main_{prefix}_{ns}")) } + fn write_file(path: &Path, content: &str) { + let _ = fs::create_dir_all(path.parent().unwrap_or(Path::new(""))); + fs::write(path, content).expect("write file"); + } + + fn create_synthetic_export_workspace(prefix: &str) -> PathBuf { + let root = unique_temp_dir(prefix); + fs::create_dir_all(&root).expect("create root"); + write_file( + &root.join("Cargo.toml"), + r#"[workspace] +members = ["crates/a", "crates/b"] +resolver = "2" +"#, + ); + write_file( + &root.join("crates").join("a").join("Cargo.toml"), + r#"[package] +name = "radroots-a" +version = "0.1.0" +edition = "2024" +description = "crate a" +repository = "https://example.com/a" +homepage = "https://example.com/a" +documentation = "https://docs.example.com/a" +readme = "README.md" + +[features] +ts-rs = [] +"#, + ); + write_file( + &root.join("crates").join("a").join("src").join("lib.rs"), + r#"pub fn crate_a() {} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + #[test] + fn write_ts_exports() { + if let Ok(path) = std::env::var("RADROOTS_TS_RS_EXPORT_DIR") { + let export_dir = PathBuf::from(path); + let _ = fs::create_dir_all(&export_dir); + fs::write( + export_dir.join("types.ts"), + "export type Probe = { id: string };\n", + ) + .expect("write generated types"); + } + } +} +"#, + ); + write_file( + &root.join("crates").join("b").join("Cargo.toml"), + r#"[package] +name = "radroots-b" +version = "0.1.0" +edition = "2024" +publish = false +"#, + ); + write_file( + &root.join("crates").join("b").join("src").join("lib.rs"), + "pub fn crate_b() {}\n", + ); + write_file( + &root.join("crates").join("core").join("src").join("unit.rs"), + r#"pub enum RadrootsCoreUnitDimension { + Count, + Mass, + Volume, +} +"#, + ); + write_file( + &root.join("contract").join("manifest.toml"), + r#"[contract] +name = "radroots-contract" +version = "1.0.0" +source = "synthetic" + +[surface] +model_crates = ["radroots-a"] +algorithm_crates = ["radroots-b"] +wasm_crates = ["radroots-a-wasm"] + +[policy] +exclude_internal_workspace_crates = true +require_reproducible_exports = true +require_conformance_vectors = true +"#, + ); + write_file( + &root.join("contract").join("version.toml"), + r#"[contract] +version = "1.0.0" +stability = "alpha" + +[semver] +major_on = ["breaking"] +minor_on = ["feature"] +patch_on = ["fix"] + +[compatibility] +requires_conformance_pass = true +requires_export_manifest_diff = true +requires_release_notes = true +"#, + ); + write_file( + &root.join("contract").join("exports").join("ts.toml"), + r#"[language] +id = "ts" +repository = "sdk-typescript" + +[packages] +"radroots-a" = "@radroots/a" + +[artifacts] +models_dir = "src/generated" +constants_dir = "src/generated" +wasm_dist_dir = "dist" +manifest_file = "export-manifest.json" +"#, + ); + write_file( + &root.join("contract").join("coverage").join("policy.toml"), + r#"[gate] +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 +require_branches = true + +[required] +crates = ["radroots-a", "radroots-b"] +"#, + ); + write_file( + &root + .join("contract") + .join("release") + .join("publish-set.toml"), + r#"[release] +version = "1.0.0" + +[publish] +crates = ["radroots-a"] + +[internal] +crates = ["radroots-b"] + +[publish_order] +crates = ["radroots-a"] +"#, + ); + root + } + #[test] fn workspace_root_resolves_and_parse_helpers_cover_branches() { let root = workspace_root(); @@ -272,6 +471,9 @@ mod tests { let fallback = workspace_root_with_override(Some(" ")); assert!(fallback.join("Cargo.toml").exists()); + + let default_root = workspace_root_with_override(None); + assert!(default_root.join("Cargo.toml").exists()); } #[test] @@ -289,52 +491,54 @@ mod tests { #[test] fn export_wrappers_cover_success_and_error_paths() { - let _guard = workspace_lock().lock().expect("lock workspace"); - let root = workspace_root(); + let _guard = lock_workspace(); + let root = create_synthetic_export_workspace("export_wrappers"); let out_dir = unique_temp_dir("export_wrappers"); fs::create_dir_all(&out_dir).expect("create out dir"); let invalid_args = vec!["--bad".to_string()]; - assert!(export_ts_models(&invalid_args).is_err()); - assert!(export_ts_constants(&invalid_args).is_err()); - assert!(export_ts_wasm(&invalid_args).is_err()); - assert!(export_manifest(&invalid_args).is_err()); - assert!(export_ts(&invalid_args).is_err()); - assert!(export_ts_crate(&invalid_args).is_err()); - - let ts_rs_root = root.join("target").join("ts-rs"); - fs::create_dir_all(ts_rs_root.join("core")).expect("create ts-rs core dir"); - fs::write( - ts_rs_root.join("core").join("types.ts"), - "export type CoreProbe = { id: string };\n", - ) - .expect("write core types"); + assert!(export_ts_models_with_root(&invalid_args, &root).is_err()); + assert!(export_ts_constants_with_root(&invalid_args, &root).is_err()); + assert!(export_ts_wasm_with_root(&invalid_args, &root).is_err()); + assert!(export_manifest_with_root(&invalid_args, &root).is_err()); + assert!(export_ts_with_root(&invalid_args, &root).is_err()); + assert!(export_ts_crate_with_root(&invalid_args, &root).is_err()); let args = vec!["--out".to_string(), out_dir.display().to_string()]; - export_manifest(&args).expect("export manifest"); - export_ts_wasm(&args).expect("export wasm"); - export_ts_constants(&args).expect("export constants"); - export_ts_models(&args).expect("export models"); + export_ts_with_root(&args, &root).expect("export ts bundle"); + export_manifest_with_root(&args, &root).expect("export manifest"); + export_ts_wasm_with_root(&args, &root).expect("export wasm"); + export_ts_constants_with_root(&args, &root).expect("export constants"); + export_ts_models_with_root(&args, &root).expect("export models"); let crate_args = vec![ "--crate".to_string(), - "core".to_string(), + "a".to_string(), "--out".to_string(), out_dir.display().to_string(), ]; - export_ts_crate(&crate_args).expect("export ts crate"); - - let bundle_args = vec!["--out".to_string(), out_dir.display().to_string()]; - export_ts(&bundle_args).expect("export ts bundle"); + export_ts_crate_with_root(&crate_args, &root).expect("export ts crate"); assert!(out_dir.join("ts").exists()); let _ = fs::remove_dir_all(out_dir); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn lock_workspace_recovers_from_poisoned_mutex() { + let handle = std::thread::spawn(|| { + let _guard = workspace_lock().lock().expect("lock workspace"); + panic!("poison workspace lock"); + }); + assert!(handle.join().is_err()); + + let _guard = lock_workspace(); } #[test] fn contract_and_coverage_dispatchers_execute() { - let _guard = workspace_lock().lock().expect("lock workspace"); + let _guard = lock_workspace(); let root = workspace_root(); let out_dir = unique_temp_dir("coverage_dispatch"); fs::create_dir_all(&out_dir).expect("create out dir"); @@ -349,12 +553,9 @@ mod tests { fs::remove_file(&coverage_refresh_path).expect("remove existing coverage refresh"); let parent = coverage_refresh_path.parent().expect("coverage parent"); fs::create_dir_all(parent).expect("create coverage parent"); - let required_raw = fs::read_to_string( - root.join("contract") - .join("coverage") - .join("policy.toml"), - ) - .expect("read coverage policy contract"); + let required_raw = + fs::read_to_string(root.join("contract").join("coverage").join("policy.toml")) + .expect("read coverage policy contract"); let required_toml = toml::from_str::<toml::Value>(&required_raw).expect("parse coverage policy contract"); let required_crates = required_toml @@ -434,7 +635,7 @@ mod tests { #[test] fn run_sdk_dispatches_export_and_validate_commands() { - let _guard = workspace_lock().lock().expect("lock workspace"); + let _guard = lock_workspace(); assert!(run_sdk(&["export-ts".to_string(), "--bad".to_string()]).is_err()); assert!(run_sdk(&["export-ts-crate".to_string(), "--bad".to_string()]).is_err()); assert!(run_sdk(&["export-ts-models".to_string(), "--bad".to_string()]).is_err()); diff --git a/docs/nix.md b/docs/nix.md @@ -7,7 +7,7 @@ This workspace uses Nix as the canonical development and CI environment contract macOS: ```bash -sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) +sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon ``` Linux with systemd: @@ -103,7 +103,7 @@ Repo-aware flows stay behind `nix run` apps because they need a real checkout: - `sdk export-ts` writes into repo-local `target/` - sdk sync validation runs `bun` against a checked-out `sdk-typescript` repo path -- coverage refresh and release preflight produce repo-local artifacts +- coverage refresh and release preflight produce repo-local artifacts derived from measured per-crate gate reports - wasm packaging writes package output directories - publish commands read runtime tokens and the live checkout state diff --git a/flake.nix b/flake.nix @@ -15,7 +15,8 @@ }; }; - outputs = inputs@{ flake-parts, ... }: + outputs = + inputs@{ flake-parts, ... }: flake-parts.lib.mkFlake { inherit inputs; } { imports = [ inputs.treefmt-nix.flakeModule ]; systems = [ @@ -47,7 +48,13 @@ treefmt = import ./treefmt.nix; apps = import ./nix/apps.nix { - inherit common config pkgs toolchains; + inherit + common + config + lib + pkgs + toolchains + ; }; checks = lib.filterAttrs (_: value: value != null) ( diff --git a/nix/apps.nix b/nix/apps.nix @@ -1,7 +1,16 @@ -{ common, config, pkgs, toolchains }: +{ + common, + config, + lib, + pkgs, + toolchains, +}: let stablePath = "export PATH=${toolchains.stable}/bin:$PATH"; coveragePath = "export PATH=${toolchains.stable}/bin:${toolchains.coverage}/bin:$PATH"; + coverageShellExec = command: '' + exec nix develop .#coverage --accept-flake-config -c sh -lc ${lib.escapeShellArg command} sh "$@" + ''; mkRepoApp = { name, @@ -92,10 +101,10 @@ in publish-crates = mkRepoApp { name = "publish-crates"; description = "Publish crates through the workspace release script"; - runtimeInputs = common.runtimeInputs.release; - command = '' - ./publish-crates.sh "$@" - ''; + runtimeInputs = [ + pkgs.nix + ]; + command = coverageShellExec ''./publish-crates.sh "$@"''; env = common.exportCoverageEnv; pathPrefix = coveragePath; }; @@ -103,10 +112,10 @@ in publish-dry-run = mkRepoApp { name = "publish-dry-run"; description = "Run a dry-run crates publish through the workspace release script"; - runtimeInputs = common.runtimeInputs.release; - command = '' - ./publish-crates.sh --dry-run "$@" - ''; + runtimeInputs = [ + pkgs.nix + ]; + command = coverageShellExec ''./publish-crates.sh --dry-run "$@"''; env = common.exportCoverageEnv; pathPrefix = coveragePath; }; @@ -114,8 +123,10 @@ in release-preflight = mkRepoApp { name = "release-preflight"; description = "Run release coverage refresh and preflight validation"; - runtimeInputs = common.runtimeInputs.coverage; - command = common.releasePreflightCommand; + runtimeInputs = [ + pkgs.nix + ]; + command = coverageShellExec common.releasePreflightCommand; env = common.exportCoverageEnv; pathPrefix = coveragePath; }; diff --git a/nix/common.nix b/nix/common.nix @@ -1,4 +1,9 @@ -{ crane, lib, pkgs, toolchains }: +{ + crane, + lib, + pkgs, + toolchains, +}: let root = ../.; cargoToml = builtins.fromTOML (builtins.readFile ../Cargo.toml); @@ -9,9 +14,8 @@ let repoSource = lib.sources.cleanSource root; cargoSource = lib.fileset.toSource { root = root; - fileset = lib.fileset.intersection - (lib.fileset.fromSource repoSource) - (lib.fileset.unions [ + fileset = lib.fileset.intersection (lib.fileset.fromSource repoSource) ( + lib.fileset.unions [ ../Cargo.toml ../Cargo.lock ../Makefile @@ -22,44 +26,65 @@ let ../contract ../crates ../scripts - ]); + ] + ); }; - sharedEnv = { + baseEnv = { CARGO_TERM_COLOR = "always"; LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; - SODIUM_USE_PKG_CONFIG = "1"; + } + // lib.optionalAttrs pkgs.stdenv.isDarwin { + CC = "clang"; + CXX = "clang++"; + SDKROOT = pkgs.apple-sdk_14.sdkroot; + MACOSX_DEPLOYMENT_TARGET = pkgs.stdenv.hostPlatform.darwinMinVersion; + }; + sharedEnv = baseEnv // { + PKG_CONFIG_PATH = lib.makeSearchPathOutput "dev" "lib/pkgconfig" stableRuntimeInputs; }; coverageEnv = sharedEnv // { RADROOTS_COVERAGE_CARGO = "${toolchains.coverage}/bin/cargo"; }; + cargoLlvmCov = + (pkgs.callPackage "${pkgs.path}/pkgs/by-name/ca/cargo-llvm-cov/package.nix" { }).overrideAttrs + (old: { + doCheck = false; + meta = old.meta // { + broken = false; + }; + }); exportEnv = env: lib.concatStringsSep "\n" ( lib.mapAttrsToList (name: value: "export ${name}=${lib.escapeShellArg value}") env ); - stableRuntimeInputs = with pkgs; [ - toolchains.stable - clang - coreutils - curl - findutils - gawk - gitMinimal - gnugrep - gnumake - gnused - jq - libsodium - llvmPackages.libclang - pkg-config - python3 - ] ++ darwinBuildInputs; + stableRuntimeInputs = + with pkgs; + [ + toolchains.stable + clang + coreutils + curl + findutils + gawk + gitMinimal + gnugrep + gnumake + gnused + jq + libsodium + llvmPackages.llvm + llvmPackages.libclang + pkg-config + python3 + ] + ++ darwinBuildInputs; syncRuntimeInputs = stableRuntimeInputs ++ [ pkgs.bun ]; coverageRuntimeInputs = stableRuntimeInputs ++ [ toolchains.coverage - pkgs.cargo-llvm-cov + cargoLlvmCov ]; wasmRuntimeInputs = stableRuntimeInputs ++ [ pkgs.wasm-pack @@ -92,11 +117,13 @@ let ]; buildInputs = [ pkgs.libsodium - ] ++ darwinBuildInputs; + ] + ++ darwinBuildInputs; inherit (sharedEnv) CARGO_TERM_COLOR LIBCLANG_PATH - SODIUM_USE_PKG_CONFIG; + PKG_CONFIG_PATH + ; }; cargoArtifacts = craneLib.buildDepsOnly commonCraneArgs; xtaskPackage = craneLib.buildPackage ( @@ -186,144 +213,113 @@ let bun run test ''; coverageReportCommand = '' - mkdir -p target/sdk-coverage - : > target/sdk-coverage/coverage-report-status.txt + rm -rf target/sdk-coverage + mkdir -p target/sdk-coverage + : > target/sdk-coverage/coverage-report-status.txt - workspace_crates_file="$(mktemp)" - required_crates_file="$(mktemp)" - trap 'rm -f "$workspace_crates_file" "$required_crates_file"' EXIT + workspace_crates_file="$(mktemp)" + required_crates_file="$(mktemp)" + trap 'rm -f "$workspace_crates_file" "$required_crates_file"' EXIT - cargo run -q -p xtask -- sdk coverage workspace-crates > "$workspace_crates_file" - while IFS= read -r crate; do - [ -n "''${crate}" ] || continue - safe_crate="''${crate//-/_}" - run_dir="target/sdk-coverage/''${safe_crate}" - mkdir -p "''${run_dir}" - status="ok" + cargo run -q -p xtask -- sdk coverage workspace-crates > "$workspace_crates_file" + while IFS= read -r crate; do + [ -n "''${crate}" ] || continue + safe_crate="''${crate//-/_}" + run_dir="target/sdk-coverage/''${safe_crate}" + mkdir -p "''${run_dir}" + status="ok" - if ! cargo run -q -p xtask -- sdk coverage run-crate --crate "''${crate}" --out "''${run_dir}"; then - status="run-failed" - fi + if ! cargo run -q -p xtask -- sdk coverage run-crate --crate "''${crate}" --out "''${run_dir}"; then + status="run-failed" + fi - if [ "''${status}" = "ok" ] && ! cargo run -q -p xtask -- sdk coverage report \ - --scope "''${crate}" \ - --summary "''${run_dir}/coverage-summary.json" \ - --lcov "''${run_dir}/coverage-lcov.info" \ - --out "''${run_dir}/coverage-gate-summary.json" \ - --fail-under-exec-lines 0 \ - --fail-under-functions 0 \ - --fail-under-regions 0 \ - --fail-under-branches 0; then - status="report-failed" - fi + if [ "''${status}" = "ok" ] && ! cargo run -q -p xtask -- sdk coverage report \ + --scope "''${crate}" \ + --summary "''${run_dir}/coverage-summary.json" \ + --lcov "''${run_dir}/coverage-lcov.info" \ + --out "''${run_dir}/coverage-gate-summary.json" \ + --fail-under-exec-lines 0 \ + --fail-under-functions 0 \ + --fail-under-regions 0 \ + --fail-under-branches 0; then + status="report-failed" + fi - if [ "''${status}" != "ok" ]; then - cat > "''${run_dir}/coverage-gate-summary.json" <<EOF - { - "scope": "''${crate}", - "thresholds": { - "executable_lines": 0, - "functions": 0, - "regions": 0, - "branches": 0, - "branches_required": false - }, - "measured": { - "executable_lines_percent": 0, - "executable_lines_source": "da", - "functions_percent": 0, - "branches_percent": null, - "branches_available": false, - "summary_lines_percent": 0, - "summary_regions_percent": 0 - }, - "counts": { - "executable_lines": { - "covered": 0, - "total": 0 - }, - "branches": { - "covered": 0, - "total": 0 + if [ "''${status}" != "ok" ]; then + cat > "''${run_dir}/coverage-gate-summary.json" <<EOF + { + "scope": "''${crate}", + "thresholds": { + "executable_lines": 0, + "functions": 0, + "regions": 0, + "branches": 0, + "branches_required": false + }, + "measured": { + "executable_lines_percent": 0, + "executable_lines_source": "da", + "functions_percent": 0, + "branches_percent": null, + "branches_available": false, + "summary_lines_percent": 0, + "summary_regions_percent": 0 + }, + "counts": { + "executable_lines": { + "covered": 0, + "total": 0 + }, + "branches": { + "covered": 0, + "total": 0 + } + }, + "result": { + "pass": false, + "fail_reasons": [ + "''${status}" + ] + } } - }, - "result": { - "pass": false, - "fail_reasons": [ - "''${status}" - ] - } - } -EOF - fi + EOF + fi - echo "''${crate}:''${status}" >> target/sdk-coverage/coverage-report-status.txt - done < "$workspace_crates_file" + echo "''${crate}:''${status}" >> target/sdk-coverage/coverage-report-status.txt + done < "$workspace_crates_file" - cargo run -q -p xtask -- sdk coverage required-crates > "$required_crates_file" - while IFS= read -r crate; do - [ -n "''${crate}" ] || continue - safe_crate="''${crate//-/_}" - crate_dir="target/sdk-coverage/''${safe_crate}" - crate_status="$(awk -F: -v crate="''${crate}" '$1 == crate { status = $2 } END { print status }' target/sdk-coverage/coverage-report-status.txt)" + cargo run -q -p xtask -- sdk coverage required-crates > "$required_crates_file" + while IFS= read -r crate; do + [ -n "''${crate}" ] || continue + safe_crate="''${crate//-/_}" + crate_dir="target/sdk-coverage/''${safe_crate}" + crate_status="$(awk -F: -v crate="''${crate}" '$1 == crate { status = $2 } END { print status }' target/sdk-coverage/coverage-report-status.txt)" - if [ ! -f "''${crate_dir}/coverage-summary.json" ] || [ ! -f "''${crate_dir}/coverage-lcov.info" ]; then - fail_reason="missing-coverage-artifacts" - if [ -n "''${crate_status}" ] && [ "''${crate_status}" != "ok" ]; then - fail_reason="''${crate_status}" - fi + if [ ! -f "''${crate_dir}/coverage-summary.json" ] || [ ! -f "''${crate_dir}/coverage-lcov.info" ]; then + fail_reason="missing-coverage-artifacts" + if [ -n "''${crate_status}" ] && [ "''${crate_status}" != "ok" ]; then + fail_reason="''${crate_status}" + fi - cat > "''${crate_dir}/coverage-gate-blocking.json" <<EOF - { - "scope": "''${crate}-blocking", - "thresholds": { - "executable_lines": 100, - "functions": 100, - "regions": 100, - "branches": 100, - "branches_required": true - }, - "measured": { - "executable_lines_percent": 0, - "executable_lines_source": "da", - "functions_percent": 0, - "branches_percent": null, - "branches_available": false, - "summary_lines_percent": 0, - "summary_regions_percent": 0 - }, - "counts": { - "executable_lines": { - "covered": 0, - "total": 0 - }, - "branches": { - "covered": 0, - "total": 0 - } - }, - "result": { - "pass": false, - "fail_reasons": [ - "''${fail_reason}" - ] - } - } -EOF - continue - fi + cargo run -q -p xtask -- sdk coverage report-missing \ + --scope "''${crate}-blocking" \ + --out "''${crate_dir}/coverage-gate-blocking.json" \ + --reason "''${fail_reason}" + continue + fi - cargo run -q -p xtask -- sdk coverage report \ - --scope "''${crate}-blocking" \ - --summary "''${crate_dir}/coverage-summary.json" \ - --lcov "''${crate_dir}/coverage-lcov.info" \ - --out "''${crate_dir}/coverage-gate-blocking.json" \ - --policy-gate - done < "$required_crates_file" + cargo run -q -p xtask -- sdk coverage report \ + --scope "''${crate}-blocking" \ + --summary "''${crate_dir}/coverage-summary.json" \ + --lcov "''${crate_dir}/coverage-lcov.info" \ + --out "''${crate_dir}/coverage-gate-blocking.json" \ + --policy-gate + done < "$required_crates_file" ''; in { inherit + cargoLlvmCov cargoArtifacts checkCommand commonCraneArgs @@ -339,7 +335,8 @@ in validateSdkTypescriptCommand version wasmBuildsCommand - xtaskPackage; + xtaskPackage + ; exportCoverageEnv = exportEnv coverageEnv; exportSharedEnv = exportEnv sharedEnv; diff --git a/nix/devshells.nix b/nix/devshells.nix @@ -1,4 +1,8 @@ -{ common, pkgs, toolchains }: +{ + common, + pkgs, + toolchains, +}: let defaultHook = '' ${common.exportSharedEnv} @@ -12,7 +16,7 @@ in { default = pkgs.mkShell { packages = common.runtimeInputs.wasm ++ [ - pkgs.cargo-llvm-cov + common.cargoLlvmCov ]; shellHook = defaultHook; }; diff --git a/nix/toolchains.nix b/nix/toolchains.nix @@ -2,7 +2,7 @@ let toolchain = builtins.fromTOML (builtins.readFile ../rust-toolchain.toml); stableVersion = toolchain.toolchain.channel; - stableTargets = toolchain.toolchain.targets or []; + stableTargets = toolchain.toolchain.targets or [ ]; stableExtensions = [ "clippy" "rust-analyzer" diff --git a/publish-crates.sh b/publish-crates.sh @@ -6,27 +6,25 @@ cd "$root_dir" mode="publish" case "${1:-}" in - --dry-run) - mode="dry-run" - shift - ;; - --publish) - mode="publish" - shift - ;; - "" ) - ;; - *) - ;; +--dry-run) + mode="dry-run" + shift + ;; +--publish) + mode="publish" + shift + ;; +"") ;; +*) ;; esac requested="${*:-}" -if [[ "$mode" == "publish" ]] && [[ -z "${CARGO_REGISTRY_TOKEN:-}" ]] && [[ -n "${CRATES_IO_TOKEN:-}" ]]; then +if [[ $mode == "publish" ]] && [[ -z ${CARGO_REGISTRY_TOKEN:-} ]] && [[ -n ${CRATES_IO_TOKEN:-} ]]; then export CARGO_REGISTRY_TOKEN="${CRATES_IO_TOKEN}" fi -if [[ "$mode" == "publish" ]] && [[ -z "${CARGO_REGISTRY_TOKEN:-}" ]]; then +if [[ $mode == "publish" ]] && [[ -z ${CARGO_REGISTRY_TOKEN:-} ]]; then echo "set CARGO_REGISTRY_TOKEN or CRATES_IO_TOKEN before publish" exit 1 fi diff --git a/rust-toolchain-coverage.toml b/rust-toolchain-coverage.toml @@ -1,4 +1,10 @@ [toolchain] -channel = "nightly-2026-03-19" -components = ["clippy", "rust-analyzer", "rust-src", "rustfmt", "llvm-tools-preview"] +channel = "nightly-2026-03-12" +components = [ + "clippy", + "rust-analyzer", + "rust-src", + "rustfmt", + "llvm-tools-preview", +] targets = ["wasm32-unknown-unknown"] diff --git a/scripts/ci/guard_committed_ts_artifacts.sh b/scripts/ci/guard_committed_ts_artifacts.sh @@ -3,7 +3,7 @@ set -euo pipefail tracked_artifacts="$(git ls-files 'crates/*/bindings/**')" -if [[ -n "$tracked_artifacts" ]]; then +if [[ -n $tracked_artifacts ]]; then echo "committed ts artifacts are not allowed under crates/*/bindings/**" echo "$tracked_artifacts" exit 1 diff --git a/scripts/ci/guard_no_legacy_identifiers.sh b/scripts/ci/guard_no_legacy_identifiers.sh @@ -4,11 +4,11 @@ set -euo pipefail matches="$( git grep -nI 'tangle' -- . \ ':(exclude)AGENTS.md' \ - ':(exclude)scripts/ci/guard_no_legacy_identifiers.sh' \ - || true + ':(exclude)scripts/ci/guard_no_legacy_identifiers.sh' || + true )" -if [[ -n "$matches" ]]; then +if [[ -n $matches ]]; then echo "legacy identifier 'tangle' is forbidden in tracked oss files" echo "$matches" exit 1 diff --git a/scripts/ci/release_preflight.sh b/scripts/ci/release_preflight.sh @@ -10,11 +10,10 @@ cargo run -q -p xtask -- sdk validate required_file="$(mktemp)" trap 'rm -f "$required_file"' EXIT -cargo run -q -p xtask -- sdk coverage required-crates > "$required_file" +cargo run -q -p xtask -- sdk coverage required-crates >"$required_file" +rm -rf target/coverage mkdir -p target/coverage -printf "crate\tstatus\texec\tfunc\tbranch\tregion\treport\n" > target/coverage/coverage-refresh.tsv -printf "crate\tstatus\n" > target/coverage/coverage-refresh-status.tsv while IFS= read -r crate; do [ -n "$crate" ] || continue @@ -29,10 +28,12 @@ while IFS= read -r crate; do --lcov "${out_dir}/coverage-lcov.info" \ --out "${out_dir}/gate-report.json" \ --policy-gate +done <"$required_file" - printf "%s\tpass\t100.0\t100.0\t100.0\t100.0\t%s\n" "$crate" "${out_dir}/gate-report.json" >> target/coverage/coverage-refresh.tsv - printf "%s\tpass\n" "$crate" >> target/coverage/coverage-refresh-status.tsv -done < "$required_file" +cargo run -q -p xtask -- sdk coverage refresh-summary \ + --reports-root target/coverage \ + --out target/coverage/coverage-refresh.tsv \ + --status-out target/coverage/coverage-refresh-status.tsv cargo run -q -p xtask -- sdk release preflight echo "release preflight complete" diff --git a/scripts/ci/release_publish_order.sh b/scripts/ci/release_publish_order.sh @@ -5,7 +5,7 @@ root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$root_dir" mode="${1:-publish}" -if [[ "$mode" != "publish" && "$mode" != "dry-run" ]]; then +if [[ $mode != "publish" && $mode != "dry-run" ]]; then echo "usage: scripts/ci/release_publish_order.sh [publish|dry-run] [crate names]" exit 2 fi @@ -24,7 +24,7 @@ release_version="$( ' contract/release/publish-set.toml )" -if [[ -z "$release_version" ]]; then +if [[ -z $release_version ]]; then echo "failed to resolve release.version from contract/release/publish-set.toml" exit 1 fi @@ -39,9 +39,9 @@ awk ' gsub(/[" ,]/, "", line) if (length(line) > 0) print line } -' contract/release/publish-set.toml > "$order_file" +' contract/release/publish-set.toml >"$order_file" -if [[ ! -s "$order_file" ]]; then +if [[ ! -s $order_file ]]; then echo "publish_order.crates list is empty" exit 1 fi @@ -102,10 +102,10 @@ publish_with_retry() { local retry_after retry_after="$(sed -n 's/.*Please try again after \(.*GMT\).*/\1/p' "$log_file" | head -n1)" local sleep_secs=0 - if [[ -n "$retry_after" ]]; then + if [[ -n $retry_after ]]; then sleep_secs="$(seconds_until_http_date "$retry_after")" fi - if [[ "$sleep_secs" -le 0 ]]; then + if [[ $sleep_secs -le 0 ]]; then sleep_secs=$((30 + attempt * 15)) fi echo "publish rate-limited for ${crate}; retry ${attempt} in ${sleep_secs}s" @@ -120,10 +120,10 @@ publish_with_retry() { done } -if [[ -n "$requested_raw" ]]; then +if [[ -n $requested_raw ]]; then for token in $requested_raw; do - [[ -n "$token" ]] || continue - echo "$token" >> "$requested_file" + [[ -n $token ]] || continue + echo "$token" >>"$requested_file" done sort -u "$requested_file" -o "$requested_file" @@ -132,21 +132,21 @@ if [[ -n "$requested_raw" ]]; then echo "requested crate is not in publish_order.crates: ${token}" exit 1 fi - done < "$requested_file" + done <"$requested_file" while IFS= read -r crate; do - [[ -n "$crate" ]] || continue + [[ -n $crate ]] || continue if grep -Fxq "$crate" "$requested_file"; then - echo "$crate" >> "$selected_file" + echo "$crate" >>"$selected_file" fi - done < "$order_file" + done <"$order_file" else cp "$order_file" "$selected_file" fi while IFS= read -r crate; do [ -n "$crate" ] || continue - if [[ "$mode" == "dry-run" ]]; then + if [[ $mode == "dry-run" ]]; then log_file="$(mktemp)" if cargo publish --dry-run --locked --allow-dirty -p "$crate" >"$log_file" 2>&1; then cat "$log_file" @@ -155,7 +155,7 @@ while IFS= read -r crate; do fi missing_dep="$(sed -n 's/.*no matching package named `\([^`]*\)`.*/\1/p' "$log_file" | head -n1)" - if [[ -n "$missing_dep" ]] && grep -Fxq "$missing_dep" "$order_file"; then + if [[ -n $missing_dep ]] && grep -Fxq "$missing_dep" "$order_file"; then echo "dry-run defer for ${crate}: dependency ${missing_dep} is not yet published" rm -f "$log_file" continue @@ -176,12 +176,12 @@ while IFS= read -r crate; do if crate_version_visible "$crate"; then break fi - if [[ "$attempt" == "30" ]]; then + if [[ $attempt == "30" ]]; then echo "crate ${crate} version ${release_version} not visible on crates.io after publish" exit 1 fi sleep 10 done -done < "$selected_file" +done <"$selected_file" echo "publish sequence complete for release ${release_version}"