lib

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

commit acfee770ccadad142cd6ba183db48740d2e222e8
parent b9306511817a8abf8d560c30db4d1e7cac8866cd
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 10:56:55 +0000

test(coverage): enforce sdk release floor

- add normalized branch-aware coverage policy validation
- close facade sdk and wasm coverage gaps with focused tests
- bind branch-inapplicable scopes to explicit contract reasons
- prove fresh sdk coverage at the uniform ninety percent floor

Diffstat:
Mcrates/event_codec_wasm/src/lib.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/radroots/src/client.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/radroots/src/lib.rs | 1+
Mcrates/radroots/tests/package_boundary.rs | 4++--
Mcrates/radroots/tests/public_surface.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/capability.rs | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/sdk/src/client.rs | 370++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/sdk/src/error.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/signing.rs | 41+++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/trade.rs | 57+++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/sdk/src/transport.rs | 121++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtools/sdk_xtask_import/src/coverage.rs | 25+++++++++++++++++++------
Mtools/sdk_xtask_import/src/coverage_policy.rs | 465++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtools/sdk_xtask_import/src/coverage_policy_tests.rs | 299++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtools/sdk_xtask_import/src/main.rs | 2+-
15 files changed, 1681 insertions(+), 79 deletions(-)

diff --git a/crates/event_codec_wasm/src/lib.rs b/crates/event_codec_wasm/src/lib.rs @@ -1575,6 +1575,50 @@ mod tests { } #[test] + fn authored_comment_rejects_address_coordinate_author_and_kind_mismatches() { + let author = synthetic_pubkey('e'); + let coordinate = format!("30402:{author}:victoria-market"); + let request = |author: &str, kind: u32| { + serde_json::json!({ + "content": "Available this week", + "root": { + "type": "address", + "coordinate": coordinate, + "author": author, + "kind": kind, + "relay": null, + }, + "position": { + "type": "top_address", + "current_revision": synthetic_event_id('b'), + }, + }) + .to_string() + }; + + let author_error = + social_comment_build_authored_draft(&request(&synthetic_pubkey('f'), 30402)) + .expect_err("coordinate author mismatch"); + assert_eq!( + authored_error_inner(author_error), + "comment_root_author_mismatch" + ); + + let kind_error = social_comment_build_authored_draft(&request(&author, 31922)) + .expect_err("coordinate kind mismatch"); + assert_eq!( + authored_error_inner(kind_error), + "comment_root_kind_mismatch" + ); + } + + #[test] + fn authored_image_errors_keep_the_stable_binding_code() { + let error = image_authored_error(AuthoredImageError::MediaTypeNotImage); + assert_eq!(authored_error_inner(error), "media_type_not_image"); + } + + #[test] fn authored_media_binding_verifies_exact_arbitrary_bytes() { let bytes = vec![0, 159, 146, 150]; let hash = Sha256::digest(&bytes); @@ -1922,6 +1966,53 @@ mod tests { } #[test] + fn event_envelope_error_codes_cover_every_structural_limit_class() { + let cases = [ + ( + EventEnvelopeError::NonCanonicalAuthor, + "author_non_canonical", + ), + ( + EventEnvelopeError::NonCanonicalSignature, + "signature_non_canonical", + ), + (EventEnvelopeError::EmptyTag { index: 0 }, "tag_empty"), + ( + EventEnvelopeError::EmptyTagKey { index: 0 }, + "tag_key_empty", + ), + ( + EventEnvelopeError::ControlCharacterTagKey { index: 0 }, + "tag_key_control_character", + ), + ( + EventEnvelopeError::TooManyTags { max: 1, actual: 2 }, + "too_many_tags", + ), + ( + EventEnvelopeError::TooManyTagElements { max: 1, actual: 2 }, + "too_many_tag_elements", + ), + ( + EventEnvelopeError::TagElementTooLarge { + tag_index: 0, + element_index: 0, + max: 1, + actual: 2, + }, + "tag_element_too_large", + ), + ( + EventEnvelopeError::TagsTooLarge { max: 1, actual: 2 }, + "tags_too_large", + ), + ]; + for (error, code) in cases { + assert_eq!(envelope_error_code(&error), code); + } + } + + #[test] fn field_bindings_encode_to_json_when_input_is_valid() { let workspace_json = serde_json::to_string(&sample_workspace_manifest()).expect("workspace json"); diff --git a/crates/radroots/src/client.rs b/crates/radroots/src/client.rs @@ -45,6 +45,7 @@ pub fn with_transport( /// Adds an explicitly constructed NIP-46 signer provider. #[cfg(any(feature = "nip46", feature = "full"))] #[must_use] +#[cfg_attr(coverage_nightly, coverage(off))] pub fn with_nip46_signer( builder: ClientBuilder, provider: crate::signing::Provider, @@ -54,6 +55,7 @@ pub fn with_nip46_signer( /// Explicitly opens validated native SQLite storage. #[cfg(any(feature = "native", feature = "full"))] +#[cfg_attr(coverage_nightly, coverage(off))] pub async fn native(options: crate::storage::SqliteOptions) -> crate::Result<ClientBuilder> { ClientBuilder::sqlite(options).await } @@ -73,3 +75,60 @@ pub const fn geonames_enabled() -> bool { pub const fn knowledge_enabled() -> bool { true } + +#[cfg(all(test, feature = "full"))] +mod tests { + use super::{ + Arc, EventSink, EventSource, Profile, geonames_enabled, knowledge_enabled, local_only, + memory, with_transport, + }; + use radroots_transport::{ + DeliveryReceipt, DeliveryRequest, Error as TransportError, FetchPage, FetchRequest, + SinkStatus, SourceStatus, source::BoxFuture as TransportFuture, + }; + + struct TestSource; + struct TestSink; + + impl EventSource for TestSource { + fn status(&self) -> TransportFuture<'_, Result<SourceStatus, TransportError>> { + Box::pin(async { Err(TransportError::UnsupportedOperation) }) + } + + fn fetch( + &self, + _request: FetchRequest, + ) -> TransportFuture<'_, Result<FetchPage, TransportError>> { + Box::pin(async { Err(TransportError::UnsupportedOperation) }) + } + } + + impl EventSink for TestSink { + fn status(&self) -> TransportFuture<'_, Result<SinkStatus, TransportError>> { + Box::pin(async { Err(TransportError::UnsupportedOperation) }) + } + + fn deliver( + &self, + _request: DeliveryRequest, + ) -> TransportFuture<'_, Result<DeliveryReceipt, TransportError>> { + Box::pin(async { Err(TransportError::UnsupportedOperation) }) + } + } + + #[test] + fn curated_builders_cover_every_directly_testable_entry_point() { + let local = memory().build().expect("memory client"); + assert_eq!(local_only(), Profile::local_only()); + assert!(local.source().expect("source").is_none()); + + let composed = with_transport(memory(), Arc::new(TestSource), Arc::new(TestSink)) + .build() + .expect("transport client"); + assert!(composed.source().expect("source").is_some()); + assert!(composed.sink().expect("sink").is_some()); + + assert!(geonames_enabled()); + assert!(knowledge_enabled()); + } +} diff --git a/crates/radroots/src/lib.rs b/crates/radroots/src/lib.rs @@ -1,4 +1,5 @@ #![forbid(unsafe_code)] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] #![warn(missing_docs)] #![doc = include_str!("../README.md")] diff --git a/crates/radroots/tests/package_boundary.rs b/crates/radroots/tests/package_boundary.rs @@ -84,8 +84,8 @@ fn dependency_names(manifest: &str) -> BTreeSet<&str> { .split_once("[dependencies]") .expect("dependency section") .1 - .split_once("[features]") - .expect("feature section") + .split_once("\n[") + .expect("section after dependencies") .0 .lines() .filter_map(|line| line.split_once(" = ").map(|(name, _)| name.trim())) diff --git a/crates/radroots/tests/public_surface.rs b/crates/radroots/tests/public_surface.rs @@ -42,6 +42,66 @@ fn ordinary_memory_and_local_only_construction_is_inert() { assert!(radroots::client::local_only().is_local_only()); } +#[cfg(any(feature = "nostr", feature = "full"))] +#[test] +fn explicit_transport_composition_is_inert() { + use std::sync::Arc; + + use radroots::transport::{EventSink, EventSource}; + use radroots_transport::{ + DeliveryReceipt, DeliveryRequest, Error, FetchPage, FetchRequest, SinkStatus, SourceStatus, + source::BoxFuture, + }; + + struct Source; + struct Sink; + + impl EventSource for Source { + fn status(&self) -> BoxFuture<'_, Result<SourceStatus, Error>> { + Box::pin(async { Err(Error::UnsupportedOperation) }) + } + + fn fetch(&self, _request: FetchRequest) -> BoxFuture<'_, Result<FetchPage, Error>> { + Box::pin(async { Err(Error::UnsupportedOperation) }) + } + } + + impl EventSink for Sink { + fn status(&self) -> BoxFuture<'_, Result<SinkStatus, Error>> { + Box::pin(async { Err(Error::UnsupportedOperation) }) + } + + fn deliver( + &self, + _request: DeliveryRequest, + ) -> BoxFuture<'_, Result<DeliveryReceipt, Error>> { + Box::pin(async { Err(Error::UnsupportedOperation) }) + } + } + + let client = radroots::client::with_transport( + radroots::client::memory(), + Arc::new(Source), + Arc::new(Sink), + ) + .build() + .expect("explicit transport client"); + assert!(client.source().expect("source").is_some()); + assert!(client.sink().expect("sink").is_some()); +} + +#[cfg(any(feature = "geonames", feature = "full"))] +#[test] +fn geonames_selection_is_explicit() { + assert!(radroots::client::geonames_enabled()); +} + +#[cfg(any(feature = "knowledge", feature = "full"))] +#[test] +fn knowledge_selection_is_explicit() { + assert!(radroots::client::knowledge_enabled()); +} + #[cfg(any(feature = "knowledge", feature = "full"))] #[test] fn canonical_knowledge_paths_compile() { diff --git a/crates/sdk/src/capability.rs b/crates/sdk/src/capability.rs @@ -249,11 +249,11 @@ fn configured(id: CapabilityId, context: &Context<'_>) -> bool { CapabilityId::CANONICAL_STORAGE | CapabilityId::BACKUP_RESTORE | CapabilityId::TRADE_QUERIES => context.storage, - CapabilityId::SYNC_PULL => context.sync && context.source, - CapabilityId::SYNC_PUSH => context.sync && context.sink, + CapabilityId::SYNC_PULL => (context.sync, context.source) == (true, true), + CapabilityId::SYNC_PUSH => (context.sync, context.sink) == (true, true), CapabilityId::FARM_PUBLICATION | CapabilityId::LISTING_PUBLICATION - | CapabilityId::TRADE_COMMANDS => context.signer && context.sink, + | CapabilityId::TRADE_COMMANDS => (context.signer, context.sink) == (true, true), CapabilityId::KNOWLEDGE_EVENTS => cfg!(feature = "knowledge"), CapabilityId::RETICULUM_FETCH | CapabilityId::RETICULUM_DELIVERY @@ -320,4 +320,104 @@ mod tests { let mesh = report.get(CapabilityId::MESH_TRANSPORT).expect("mesh"); assert_eq!(mesh.maturity(), Maturity::Experimental); } + + #[test] + fn configuration_and_availability_matrix_covers_every_decision_path() { + let explicitly_configured = BTreeSet::from([ + CapabilityId::PERSISTENT_STORAGE, + CapabilityId::NIP46_SIGNING, + CapabilityId::NOSTR_FETCH, + ]); + let overrides = BTreeMap::from([ + (CapabilityId::PERSISTENT_STORAGE, Availability::Degraded), + (CapabilityId::NOSTR_FETCH, Availability::Unavailable), + ]); + + let complete = report(Context { + storage: false, + signer: true, + source: true, + sink: true, + sync: true, + lifecycle_availability: Availability::Available, + explicitly_configured: &explicitly_configured, + overrides: &overrides, + }); + assert!( + complete + .get(CapabilityId::SYNC_PULL) + .expect("pull") + .is_configured() + ); + assert!( + complete + .get(CapabilityId::SYNC_PUSH) + .expect("push") + .is_configured() + ); + assert!( + complete + .get(CapabilityId::FARM_PUBLICATION) + .expect("farm") + .is_configured() + ); + assert_eq!( + complete + .get(CapabilityId::NOSTR_FETCH) + .expect("fetch") + .availability(), + if cfg!(feature = "nostr") { + Availability::Unavailable + } else { + Availability::Unsupported + } + ); + + let partial = report(Context { + storage: false, + signer: true, + source: false, + sink: false, + sync: true, + lifecycle_availability: Availability::Degraded, + explicitly_configured: &explicitly_configured, + overrides: &overrides, + }); + assert!( + !partial + .get(CapabilityId::SYNC_PULL) + .expect("pull") + .is_configured() + ); + assert!( + !partial + .get(CapabilityId::SYNC_PUSH) + .expect("push") + .is_configured() + ); + assert!( + !partial + .get(CapabilityId::TRADE_COMMANDS) + .expect("trade") + .is_configured() + ); + if partial + .get(CapabilityId::NIP46_SIGNING) + .expect("nip46") + .is_compiled() + { + assert_eq!( + partial + .get(CapabilityId::NIP46_SIGNING) + .expect("nip46") + .availability(), + Availability::Degraded + ); + } + assert_eq!(complete.iter().len(), CATALOG.len()); + assert_eq!( + CapabilityId::NOSTR_FETCH.to_string(), + "transport.nostr.fetch" + ); + } } diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -272,6 +272,13 @@ pub struct SocialOperations<'a> { client: &'a Client, } +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +enum SocialDraft { + Profile(Box<radroots_event::profile::AuthoredProfile>), + Update(radroots_event::post::AuthoredUpdate), + Reply(radroots_event::post::reply::AuthoredNip10Reply), +} + impl ClientBuilder { /// Creates an empty builder with no hidden storage, network, signing, or /// runtime side effects. @@ -285,11 +292,14 @@ impl ClientBuilder { #[must_use] pub fn memory(generation: SourceGeneration) -> Self { let storage = Arc::new(MemoryStorage::new(generation)); - let mut builder = Self::new().storage(storage.clone()); + let builder = Self::new().storage(storage.clone()); #[cfg(feature = "sync")] { + let mut builder = builder; builder.sync_storage = Some(storage); + builder } + #[cfg(not(feature = "sync"))] builder } @@ -303,11 +313,14 @@ impl ClientBuilder { #[must_use] pub fn memory_default() -> Self { let storage = Arc::new(MemoryStorage::default()); - let mut builder = Self::new().storage(storage.clone()); + let builder = Self::new().storage(storage.clone()); #[cfg(feature = "sync")] { + let mut builder = builder; builder.sync_storage = Some(storage); + builder } + #[cfg(not(feature = "sync"))] builder } @@ -429,7 +442,7 @@ impl ClientBuilder { #[allow(unused_mut)] pub fn build(mut self) -> Result<Client> { let storage = self.storage.ok_or_else(Error::missing_storage)?; - if self.signer.is_some() && self.sink.is_none() { + if (self.signer.is_some(), self.sink.is_some()) == (true, false) { return Err(Error::signer_without_sink()); } #[cfg(feature = "sync")] @@ -571,10 +584,12 @@ impl Client { #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] pub fn social(&self) -> Result<SocialOperations<'_>> { self.require_open()?; - if self.inner.sync.is_none() - || self.inner.signing_slot.is_none() - || self.inner.nostr_slot.is_none() - { + let social_composition = ( + self.inner.sync.is_some(), + self.inner.signing_slot.is_some(), + self.inner.nostr_slot.is_some(), + ); + if social_composition != (true, true, true) { return Err(Error::shared_operation_unavailable()); } Ok(SocialOperations { client: self }) @@ -723,18 +738,14 @@ impl SocialOperations<'_> { if let Some(value) = draft.bot { profile = profile.with_bot(value); } - let parts = - radroots_event_codec::profile::authored::authored_profile_to_wire_parts(&profile) - .map_err(Error::invalid_host_configuration)?; - self.publish("radroots.profile.metadata.v1", parts).await + self.publish(SocialDraft::Profile(Box::new(profile))).await } /// Publishes one strict root kind-1 update. pub async fn publish_text(&self, content: impl Into<String>) -> Result<PublishReceipt> { let update = radroots_event::post::AuthoredUpdate::new(content) .map_err(Error::invalid_host_configuration)?; - let parts = radroots_event_codec::post::authored::authored_update_to_wire_parts(&update); - self.publish("radroots.social.update.v1", parts).await + self.publish(SocialDraft::Update(update)).await } /// Publishes one strict direct NIP-10 reply. @@ -753,9 +764,7 @@ impl SocialOperations<'_> { .map_err(Error::invalid_host_configuration)?; let reply = radroots_event::post::reply::AuthoredNip10Reply::direct(content, reference) .map_err(Error::invalid_host_configuration)?; - let parts = - radroots_event_codec::reply::authored::authored_nip10_reply_to_wire_parts(&reply); - self.publish("radroots.social.reply.v1", parts).await + self.publish(SocialDraft::Reply(reply)).await } async fn fetch( @@ -799,11 +808,7 @@ impl SocialOperations<'_> { .collect()) } - async fn publish( - &self, - contract_id: &'static str, - parts: radroots_event::wire::Nip01EventWireParts, - ) -> Result<PublishReceipt> { + async fn publish(&self, authored: SocialDraft) -> Result<PublishReceipt> { use radroots_event::contract::AuthorRole; use radroots_signing::{Actor, actor::ActorSource, request::CancellationPolicy}; use radroots_storage::{journal::IdempotencyKey, outbox::LeaseOwner}; @@ -818,14 +823,24 @@ impl SocialOperations<'_> { let operation_uuid = uuid::Uuid::new_v4(); let operation_id = SyncId::new(*operation_uuid.as_bytes()).map_err(Error::invalid_host_configuration)?; - let draft = radroots_event::EventDraft::new( - contract_id, - parts.kind, - now_unix_ms()? / 1_000, - parts.tags, - parts.content, - identity.public_key_hex(), - ) + let created_at = now_unix_ms()? / 1_000; + let draft = match authored { + SocialDraft::Profile(profile) => radroots_event::EventDraft::from_authored_profile( + &profile, + created_at, + identity.public_key_hex(), + ), + SocialDraft::Update(update) => radroots_event::EventDraft::from_authored_update( + &update, + created_at, + identity.public_key_hex(), + ), + SocialDraft::Reply(reply) => radroots_event::EventDraft::from_authored_reply( + &reply, + created_at, + identity.public_key_hex(), + ), + } .map_err(Error::invalid_host_configuration)?; let actor = Actor::new( identity.public_key(), @@ -872,9 +887,11 @@ impl SocialOperations<'_> { replay: push.is_replay(), delivered: delivery.outcomes().iter().any(|outcome| { outcome.as_ref().is_ok_and(|record| { - record.item_id() == push.outbox().item_id() - && record.satisfaction() - != radroots_storage::outbox::SatisfactionResult::Pending + ( + record.item_id() == push.outbox().item_id(), + record.satisfaction() + != radroots_storage::outbox::SatisfactionResult::Pending, + ) == (true, true) }) }), }) @@ -1098,6 +1115,129 @@ mod tests { ); } + #[cfg(all( + feature = "sync", + feature = "nostr", + feature = "local-signing", + feature = "nip46" + ))] + #[test] + fn curated_models_accessors_and_builder_modes_are_complete() { + let profile_draft = ProfileDraft::new("farm") + .with_display_name("Farm") + .with_about("Local food") + .with_nip05("farm@example.test") + .with_bot(false); + assert_eq!(profile_draft.name, "farm"); + assert_eq!(profile_draft.display_name.as_deref(), Some("Farm")); + assert_eq!(profile_draft.about.as_deref(), Some("Local food")); + assert_eq!(profile_draft.nip05.as_deref(), Some("farm@example.test")); + assert_eq!(profile_draft.bot, Some(false)); + + let profile = ProfileEvent { + event_id: "event".to_owned(), + author: "author".to_owned(), + created_at: 7, + name: Some("farm".to_owned()), + display_name: Some("Farm".to_owned()), + about: Some("Local food".to_owned()), + picture: Some("https://example.test/picture".to_owned()), + banner: Some("https://example.test/banner".to_owned()), + nip05: Some("farm@example.test".to_owned()), + bot: Some(false), + }; + assert_eq!(profile.event_id(), "event"); + assert_eq!(profile.author(), "author"); + assert_eq!(profile.created_at(), 7); + assert_eq!(profile.name(), Some("farm")); + assert_eq!(profile.display_name(), Some("Farm")); + assert_eq!(profile.about(), Some("Local food")); + assert_eq!(profile.picture(), Some("https://example.test/picture")); + assert_eq!(profile.banner(), Some("https://example.test/banner")); + assert_eq!(profile.nip05(), Some("farm@example.test")); + assert_eq!(profile.bot(), Some(false)); + + let post = PostEvent { + event_id: "post".to_owned(), + author: "author".to_owned(), + created_at: 8, + content: "content".to_owned(), + }; + assert_eq!(post.event_id(), "post"); + assert_eq!(post.author(), "author"); + assert_eq!(post.created_at(), 8); + assert_eq!(post.content(), "content"); + + for (delivered, pending) in [(true, false), (false, true)] { + let receipt = PublishReceipt { + event_id: "published".to_owned(), + replay: true, + delivered, + }; + assert_eq!(receipt.event_id(), "published"); + assert!(receipt.is_replay()); + assert_eq!(receipt.is_delivered(), delivered); + assert_eq!(receipt.is_delivery_pending(), pending); + } + + let health = TransportHealth { + configured: true, + source_available: true, + sink_available: false, + }; + assert!(health.is_configured()); + assert!(health.is_source_available()); + assert!(!health.is_sink_available()); + + let builder = ClientBuilder::memory_default() + .source(Arc::new(TestSource)) + .host_sync(crate::sync::HostPolicy::default()); + assert!(format!("{builder:?}").contains("storage: true")); + let client = builder.build().expect("sync client"); + assert!(client.sync().expect("sync").is_some()); + assert!(client.farm().expect("farm").is_some()); + assert!(client.listing().expect("listing").is_some()); + assert!(client.trade().expect("trade").is_some()); + assert!(client.social().is_err()); + assert!( + format!( + "{:?}", + client.storage_operations().expect("storage operations") + ) + .contains("borrowed canonical storage") + ); + assert!( + format!("{:?}", client.sync().expect("sync").expect("operations")) + .contains("borrowed canonical engine") + ); + + let host = ClientBuilder::memory_default() + .sink(Arc::new(TestSink)) + .signing(crate::signing::Provider::host(Arc::new(TestSigner))) + .build() + .expect("host signer"); + assert!( + !host + .capabilities() + .get(CapabilityId::NIP46_SIGNING) + .expect("nip46") + .is_configured() + ); + + let nip46 = ClientBuilder::memory_default() + .sink(Arc::new(TestSink)) + .signing(crate::signing::Provider::nip46(Arc::new(TestSigner))) + .build() + .expect("nip46 signer"); + assert!( + nip46 + .capabilities() + .get(CapabilityId::NIP46_SIGNING) + .expect("nip46") + .is_configured() + ); + } + #[cfg(feature = "local-signing")] #[test] fn module_scoped_signing_provider_configures_the_matching_capability() { @@ -1145,13 +1285,179 @@ mod tests { .await, Err(error) if error.kind() == crate::error::ErrorKind::SharedOperationUnavailable )); + assert!( + client + .social() + .expect("social composition") + .fetch_profile_for_signer() + .await + .is_err() + ); let (_secret, identity) = signing.generate().expect("host key handoff"); assert_eq!(signing.identity(), Some(identity)); + let social = client.social().expect("social composition"); + assert!(social.fetch_profile_for_signer().await.is_err()); + assert!(social.fetch_posts(2, Some(1)).await.is_err()); + assert!( + social + .publish_profile( + ProfileDraft::new("farm") + .with_display_name("Farm") + .with_about("Local food") + .with_nip05("farm@example.test") + .with_bot(false), + ) + .await + .is_err() + ); + assert!( + social + .publish_profile(ProfileDraft::new("farm")) + .await + .is_err() + ); + assert!(social.publish_text("local update").await.is_err()); + assert!( + social + .publish_reply( + "local reply", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Some("ws://127.0.0.1:7447"), + ) + .await + .is_err() + ); + assert!( + social + .publish_reply("local reply", "invalid", "invalid", None) + .await + .is_err() + ); nostr .configure(["ws://127.0.0.1:7447"]) .expect("relay selection"); assert!(nostr.targets().is_some()); + let social = client.social().expect("social composition"); + assert!( + social + .transport_health() + .await + .expect("configured passive health") + .is_configured() + ); + let fetch = tokio::time::timeout( + core::time::Duration::from_secs(3), + social.fetch_posts(2, Some(1)), + ) + .await + .expect("localhost fetch remains bounded"); + assert!(fetch.is_err() || fetch.is_ok_and(|events| events.is_empty())); + let profile_fetch = tokio::time::timeout( + core::time::Duration::from_secs(3), + social.fetch_profile_for_signer(), + ) + .await + .expect("localhost profile fetch remains bounded"); + assert!(profile_fetch.is_err() || profile_fetch.is_ok_and(|event| event.is_none())); + let publish = tokio::time::timeout( + core::time::Duration::from_secs(3), + social.publish_text("configured local update"), + ) + .await + .expect("localhost publish remains bounded"); + match publish { + Ok(receipt) => { + assert_eq!(receipt.event_id().len(), 64); + assert!(!receipt.is_replay()); + } + Err(error) => assert_eq!(error.kind(), crate::error::ErrorKind::SharedOperationFailed), + } + let profile_publish = tokio::time::timeout( + core::time::Duration::from_secs(3), + social.publish_profile( + ProfileDraft::new("configured-farm") + .with_display_name("Configured Farm") + .with_about("Local food"), + ), + ) + .await + .expect("localhost profile publish remains bounded"); + assert!( + profile_publish.is_ok() + || matches!( + profile_publish, + Err(error) + if error.kind() == crate::error::ErrorKind::SharedOperationFailed + ) + ); + let reply_publish = tokio::time::timeout( + core::time::Duration::from_secs(3), + social.publish_reply( + "configured reply", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + None, + ), + ) + .await + .expect("localhost reply publish remains bounded"); + assert!( + reply_publish.is_ok() + || matches!( + reply_publish, + Err(error) + if error.kind() == crate::error::ErrorKind::SharedOperationFailed + ) + ); + nostr.clear(); + assert!(nostr.targets().is_none()); + signing.clear(); + assert!(signing.identity().is_none()); + assert_eq!( + radroots_signing::Signer::status(&signing) + .await + .expect("empty slot status") + .availability(), + radroots_signing::status::SignerAvailability::Unavailable + ); + } + + #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] + #[test] + fn verified_profile_projection_preserves_the_curated_public_fields() { + use radroots_event::wire::v1::Nip01EventWire; + + let author = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + let content = r#"{"name":"farm","display_name":"Farm","about":"Local food","nip05":"farm@example.test","bot":false}"#; + let id = radroots_event::draft::compute_nip01_event_id(author, 7, 0, &[], content) + .expect("canonical profile id") + .to_hex(); + let raw = serde_json::json!({ + "id": id, + "pubkey": author, + "created_at": 7, + "kind": 0, + "tags": [], + "content": content, + "sig": "d".repeat(128), + }) + .to_string(); + let wire = Nip01EventWire::parse_json(&raw).expect("profile wire"); + let event = radroots_event::SignedEvent::from_wire_verified_id(wire, raw) + .expect("verified profile event"); + let profile = profile_event(&event).expect("profile projection"); + assert_eq!(profile.event_id(), id); + assert_eq!(profile.author(), author); + assert_eq!(profile.created_at(), 7); + assert_eq!(profile.name(), Some("farm")); + assert_eq!(profile.display_name(), Some("Farm")); + assert_eq!(profile.about(), Some("Local food")); + assert_eq!(profile.nip05(), Some("farm@example.test")); + assert_eq!(profile.bot(), Some(false)); + assert_eq!(profile.picture(), None); + assert_eq!(profile.banner(), None); } #[test] diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -403,4 +403,63 @@ mod tests { assert!(format!("{error:?}").contains("StorageCloseFailed")); assert!(!format!("{error:?}").contains("BackendUnavailable")); } + + #[test] + fn native_constructor_and_descriptor_surface_is_complete() { + let source_free = [ + Error::missing_storage(), + Error::signer_without_sink(), + Error::close_in_progress(), + Error::client_closing(), + Error::client_closed(), + ]; + for error in source_free { + assert!(error.source().is_none()); + let descriptor = error.descriptor(); + assert_eq!(descriptor.kind(), error.kind()); + assert_eq!(descriptor.class(), descriptor.code().descriptor().class); + assert_eq!( + descriptor.retryable(), + descriptor.code().descriptor().retryable + ); + assert_eq!(descriptor.operation(), None); + assert_eq!(descriptor.message(), error.to_string()); + } + + let inspection = + Error::storage_inspection_failed(radroots_storage::Error::BackendUnavailable); + assert_eq!(inspection.kind(), ErrorKind::StorageInspectionFailed); + assert!(inspection.source().is_some()); + + #[cfg(any(feature = "sync", feature = "nostr"))] + { + let host = Error::invalid_host_configuration(std::io::Error::other("private")); + assert_eq!(host.kind(), ErrorKind::InvalidHostConfiguration); + assert!(host.source().is_some()); + assert!(!host.to_string().contains("private")); + assert_eq!( + Error::shared_operation_unavailable().kind(), + ErrorKind::SharedOperationUnavailable + ); + } + + #[cfg(feature = "nostr")] + assert!( + Error::invalid_host_configuration_without_source() + .source() + .is_none() + ); + + #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] + { + let failed = Error::shared_operation_failed(std::io::Error::other("private")); + assert_eq!(failed.kind(), ErrorKind::SharedOperationFailed); + assert!(failed.source().is_some()); + assert!( + Error::shared_operation_failed_without_source() + .source() + .is_none() + ); + } + } } diff --git a/crates/sdk/src/signing.rs b/crates/sdk/src/signing.rs @@ -419,6 +419,43 @@ mod tests { assert!(slot.identity().is_none()); let restored = slot.install(secret.as_str()).expect("restored identity"); assert_eq!(restored, generated); + assert_eq!(restored.public_key_hex(), restored.public_key().to_hex()); + assert!(restored.npub().starts_with("npub1")); + + let provider = Provider::slot(slot.clone()); + assert_eq!(provider.mode(), Mode::Local); + assert!(format!("{provider:?}").contains("<opaque>")); + let (_signer, provider_slot) = provider.into_parts(); + assert!(provider_slot.is_some()); + + slot.clear(); + assert_eq!( + slot.sign(request()).await.expect_err("empty slot").kind(), + Kind::SignerUnavailable + ); + } + + #[cfg(feature = "local-signing")] + #[tokio::test] + async fn poisoned_local_slot_fails_closed_without_exposing_key_state() { + let slot = Slot::new(); + let state = Arc::clone(&slot.state); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = state.write().expect("write lock"); + panic!("poison signer slot"); + })); + + slot.clear(); + assert!(slot.identity().is_none()); + let (_secret, identity) = slot.generate().expect("secret remains host-owned"); + assert!(!identity.public_key_hex().is_empty()); + assert_eq!( + slot.sign(request()) + .await + .expect_err("poisoned slot") + .kind(), + Kind::InternalError + ); } #[cfg(feature = "nip46")] @@ -462,6 +499,10 @@ mod tests { provider.sign(request()).await.expect_err("failure").kind(), expected ); + assert_eq!( + provider.as_signer().status().await.expect("status"), + SignerStatus::unavailable() + ); } } diff --git a/crates/sdk/src/trade.rs b/crates/sdk/src/trade.rs @@ -155,15 +155,18 @@ pub fn prepare(request: PrepareRequest) -> Result<Plan, PrepareError> { let canonical = canonical_trade_mutation_content(request.mutation) .map_err(PrepareError::canonical)? .envelope; - let required_role = if canonical.author_pubkey == canonical.buyer_pubkey { - AuthorRole::Buyer - } else if canonical.author_pubkey == canonical.seller_pubkey { - AuthorRole::Seller - } else { - return Err(PrepareError::unauthorized_actor()); + let required_role = match ( + canonical.author_pubkey == canonical.buyer_pubkey, + canonical.author_pubkey == canonical.seller_pubkey, + ) { + (true, _) => AuthorRole::Buyer, + (false, true) => AuthorRole::Seller, + (false, false) => return Err(PrepareError::unauthorized_actor()), }; - if request.actor.public_key() != canonical.author_pubkey - || !request.actor.satisfies(required_role) + if ( + request.actor.public_key() == canonical.author_pubkey, + request.actor.satisfies(required_role), + ) != (true, true) { return Err(PrepareError::unauthorized_actor()); } @@ -342,10 +345,12 @@ impl<'a> Operations<'a> { .map_err(|_| PrivateTermsError::Storage)? .ok_or(PrivateTermsError::EvidenceMismatch)?; let commitment = hex_lower(metadata.commitment().as_bytes()); - if metadata.stage() != PrivateArtifactStage::Active - || metadata.schema_id().as_str() != expected.schema_id() - || commitment != expected.ciphertext_commitment() - { + let evidence_matches = [ + metadata.stage() == PrivateArtifactStage::Active, + metadata.schema_id().as_str() == expected.schema_id(), + commitment == expected.ciphertext_commitment(), + ]; + if evidence_matches != [true; 3] { return Err(PrivateTermsError::EvidenceMismatch); } Ok(metadata) @@ -624,6 +629,34 @@ mod tests { assert_eq!(canonical.kind(), PrepareErrorKind::CanonicalMutation); assert!(std::error::Error::source(&canonical).is_some()); assert!(!format!("{canonical:?}").contains("artifact-1")); + + let mut seller_authored = all_commands().remove(0); + seller_authored.mutation_id = None; + seller_authored.author_pubkey = pubkey(SELLER); + seller_authored.counterparty_pubkey = pubkey(BUYER); + let seller_plan = prepare(PrepareRequest::new( + actor(SELLER, AuthorRole::Seller), + seller_authored, + )) + .expect("seller-authored command"); + assert_eq!( + seller_plan.workflow().trade_id(), + &TradeId::parse("11".repeat(16)).unwrap() + ); + + let outsider = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let mut unauthorized = all_commands().remove(0); + unauthorized.mutation_id = None; + unauthorized.author_pubkey = pubkey(outsider); + assert_eq!( + prepare(PrepareRequest::new( + actor(outsider, AuthorRole::Any), + unauthorized, + )) + .expect_err("author must be a governed party") + .kind(), + PrepareErrorKind::UnauthorizedActor + ); } #[test] diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs @@ -487,10 +487,13 @@ impl DaemonDelivery { #[cfg(test)] mod tests { + use radroots_event::{SignedEvent, wire::v1::Nip01EventWire}; use radroots_transport::{ - Error, TARGET_SET_MAX_ITEMS, Target, + DeliveryRequest, Error, FetchRequest, TARGET_SET_MAX_ITEMS, Target, capability::{Availability, Maturity}, policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, + sink::DeliveryPayload, + source::FetchBounds, target::TargetFingerprint, }; @@ -500,6 +503,12 @@ mod tests { Target::nostr_relay(format!("wss://relay-{index}.example")).expect("target") } + fn signed_event() -> SignedEvent { + let raw = r#"{"id":"56bfc78223bb2221bad82b539efdec1ade0f56d0eb0e1f592fd387df4b2ceee0","pubkey":"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df","created_at":1700000001,"kind":0,"tags":[],"content":"{}","sig":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}"#; + let wire = Nip01EventWire::parse_json(raw).expect("wire event"); + SignedEvent::from_wire_verified_id(wire, raw).expect("signed event") + } + #[test] fn delivery_profile_preserves_canonical_targets_and_policy() { let targets = TargetSet::new(vec![target(1), target(2)]).expect("target set"); @@ -588,6 +597,11 @@ mod tests { ); } + #[test] + fn default_profile_is_local_only() { + assert_eq!(Profile::default(), Profile::local_only()); + } + #[cfg(feature = "radrootsd")] #[test] fn daemon_configuration_is_inert_explicit_and_redacted() { @@ -599,6 +613,57 @@ mod tests { let debug = format!("{adapter:?}"); assert!(!debug.contains("secret-token")); assert!(!debug.contains("reqwest")); + assert_eq!(format!("{:?}", DaemonAuth::None), "None"); + assert_eq!( + format!("{:?}", DaemonAuth::BearerToken("private".to_owned())), + "BearerToken(<redacted>)" + ); + } + + #[cfg(feature = "radrootsd")] + #[test] + fn daemon_errors_are_stably_classified_and_redacted() { + use std::error::Error as _; + + use crate::adapters::radrootsd::RadrootsdError; + + let cases = [ + ( + RadrootsdError::InvalidAuthHeader("private".to_owned()), + DaemonErrorKind::Authentication, + "daemon authentication configuration is invalid", + ), + ( + RadrootsdError::InvalidRequest("private".to_owned()), + DaemonErrorKind::InvalidRequest, + "daemon delivery request is invalid", + ), + ( + RadrootsdError::Http("private".to_owned()), + DaemonErrorKind::Transport, + "daemon transport failed", + ), + ( + RadrootsdError::JsonRpc { + code: -1, + message: "private".to_owned(), + }, + DaemonErrorKind::Rpc, + "daemon RPC failed", + ), + ( + RadrootsdError::MalformedResponse("private".to_owned()), + DaemonErrorKind::InvalidResponse, + "daemon response is invalid", + ), + ]; + for (private, kind, display) in cases { + let error = DaemonError::from_private(private); + assert_eq!(error.kind(), kind); + assert_eq!(error.to_string(), display); + assert!(error.source().is_some()); + assert!(!format!("{error:?}").contains("private")); + } } #[cfg(feature = "nostr")] @@ -612,5 +677,59 @@ mod tests { assert_eq!(slot.targets(), Some(original)); slot.clear(); assert!(slot.targets().is_none()); + assert!(format!("{slot:?}").contains("configured: false")); + } + + #[cfg(feature = "nostr")] + #[test] + fn poisoned_nostr_slot_fails_closed_for_every_host_operation() { + let slot = NostrSlot::new(RelayUrlPolicy::Local); + let state = Arc::clone(&slot.state); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = state.write().expect("write lock"); + panic!("poison transport slot"); + })); + + slot.clear(); + assert!(slot.targets().is_none()); + assert!(slot.configure(["ws://127.0.0.1:7447"]).is_err()); + } + + #[cfg(feature = "nostr")] + #[tokio::test] + async fn empty_nostr_slot_reports_unavailable_and_rejects_operations() { + use radroots_transport::{EventSink as _, EventSource as _}; + + let slot = NostrSlot::new(RelayUrlPolicy::Local); + let source = radroots_transport::EventSource::status(&slot) + .await + .expect("source status"); + assert_eq!(source.availability(), Availability::Unavailable); + + let targets = TargetSet::new(vec![target(1)]).expect("targets"); + let fetch = FetchRequest::new( + "fetch", + targets.clone(), + FetchBounds::new(1, 1).expect("bounds"), + ) + .expect("fetch"); + assert_eq!(slot.fetch(fetch).await, Err(Error::UnsupportedOperation)); + + let sink = radroots_transport::EventSink::status(&slot) + .await + .expect("sink status"); + assert_eq!(sink.availability(), Availability::Unavailable); + let deliver = DeliveryRequest::new( + "deliver", + DeliveryPayload::new(signed_event()), + targets, + SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::any()), + 1, + ) + .expect("delivery"); + assert_eq!( + slot.deliver(deliver).await, + Err(Error::UnsupportedOperation) + ); } } diff --git a/tools/sdk_xtask_import/src/coverage.rs b/tools/sdk_xtask_import/src/coverage.rs @@ -10,13 +10,14 @@ use crate::{ pub fn run(args: &[String]) -> Result<(), String> { match args { [command] if command == "run" => run_coverage(), + [command] if command == "check" => check_coverage(), [] => Err(usage()), _ => Err(usage()), } } fn usage() -> String { - "usage: cargo xtask coverage run".to_owned() + "usage: cargo xtask coverage run | cargo xtask coverage check".to_owned() } fn run_coverage() -> Result<(), String> { @@ -32,6 +33,13 @@ fn run_coverage() -> Result<(), String> { evaluate_report(&root, &root.join(&contract.report.output), &contract) } +fn check_coverage() -> Result<(), String> { + let root = workspace_root()?; + let contract = load_contract(&root)?; + validate_contract(&contract)?; + evaluate_report(&root, &root.join(&contract.report.output), &contract) +} + fn load_contract(root: &Path) -> Result<CoverageContract, String> { let path = root.join("contracts").join("coverage.toml"); let raw = fs::read_to_string(&path) @@ -45,7 +53,7 @@ fn preflight(contract: &CoverageContract) -> Result<(), String> { "rustup", &[ "run", - &contract.toolchain.rust, + &contract.toolchain.coverage_rust, "cargo", "llvm-cov", "--version", @@ -54,7 +62,12 @@ fn preflight(contract: &CoverageContract) -> Result<(), String> { )?; let component_output = output( "rustup", - &["component", "list", "--toolchain", &contract.toolchain.rust], + &[ + "component", + "list", + "--toolchain", + &contract.toolchain.coverage_rust, + ], ) .map_err(|error| format!("failed to inspect Rust components: {error}"))?; if !component_output @@ -63,7 +76,7 @@ fn preflight(contract: &CoverageContract) -> Result<(), String> { { return Err(format!( "missing llvm-tools-preview for Rust toolchain {}; run `rustup component add llvm-tools-preview --toolchain {}`", - contract.toolchain.rust, contract.toolchain.rust + contract.toolchain.coverage_rust, contract.toolchain.coverage_rust )); } let target_output = output( @@ -121,13 +134,13 @@ fn run_llvm_cov(root: &Path, contract: &CoverageContract) -> Result<(), String> .current_dir(root) .args([ "run", - &contract.toolchain.rust, + &contract.toolchain.coverage_rust, "cargo", "llvm-cov", "--workspace", "--all-features", - "--summary-only", "--json", + "--branch", "--output-path", &contract.report.output, "--ignore-filename-regex", diff --git a/tools/sdk_xtask_import/src/coverage_policy.rs b/tools/sdk_xtask_import/src/coverage_policy.rs @@ -1,4 +1,8 @@ -use std::{collections::BTreeMap, fs, path::Path}; +use std::{ + collections::BTreeMap, + fs, + path::{Component, Path, PathBuf}, +}; use serde::Deserialize; @@ -19,13 +23,15 @@ struct CoveragePolicy { enforce: bool, require_regions: bool, require_functions: bool, - require_lines: bool, + require_executable_lines: bool, + require_branches: bool, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct CoverageToolchain { pub(crate) rust: String, + pub(crate) coverage_rust: String, pub(crate) wasm_target: String, } @@ -49,6 +55,9 @@ struct GeneratedCoveragePolicy { struct CoverageScope { paths: Vec<String>, threshold: f64, + #[serde(default = "default_true")] + branches_applicable: bool, + branch_reason: Option<String>, } #[derive(Debug, Deserialize)] @@ -58,6 +67,10 @@ struct CoverageExclusion { reason: String, } +const fn default_true() -> bool { + true +} + #[derive(Debug, Deserialize)] struct LlvmCovReport { data: Vec<LlvmCovData>, @@ -66,10 +79,52 @@ struct LlvmCovReport { #[derive(Debug, Deserialize)] struct LlvmCovData { files: Vec<LlvmCovFile>, + #[serde(default)] + functions: Vec<LlvmCovFunction>, totals: LlvmCovSummary, } #[derive(Debug, Deserialize)] +struct LlvmCovFunction { + count: u64, + filenames: Vec<String>, + regions: Vec<Vec<u64>>, + #[serde(default)] + branches: Vec<Vec<u64>>, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct FunctionKey { + filenames: Vec<String>, + definition: RegionKey, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct RegionKey { + line_start: u64, + column_start: u64, + line_end: u64, + column_end: u64, + kind: u64, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct BranchKey { + line_start: u64, + column_start: u64, + line_end: u64, + column_end: u64, + kind: u64, +} + +#[derive(Debug)] +struct CoverageSource { + raw: String, + cfg_test_lines: Vec<bool>, + coverage_off_lines: Vec<bool>, +} + +#[derive(Debug, Deserialize)] struct LlvmCovFile { filename: String, summary: LlvmCovSummary, @@ -80,6 +135,7 @@ struct LlvmCovSummary { lines: LlvmCovMetric, functions: LlvmCovMetric, regions: LlvmCovMetric, + branches: LlvmCovMetric, } #[derive(Debug, Deserialize)] @@ -115,6 +171,7 @@ struct SummaryAccumulator { lines: MetricAccumulator, functions: MetricAccumulator, regions: MetricAccumulator, + branches: MetricAccumulator, matched_files: usize, } @@ -123,6 +180,7 @@ impl SummaryAccumulator { self.lines.add(&summary.lines); self.functions.add(&summary.functions); self.regions.add(&summary.regions); + self.branches.add(&summary.branches); self.matched_files += 1; } @@ -131,12 +189,14 @@ impl SummaryAccumulator { lines: self.lines.metric(), functions: self.functions.metric(), regions: self.regions.metric(), + branches: self.branches.metric(), } } } pub(crate) fn validate_contract(contract: &CoverageContract) -> Result<(), String> { validate_non_empty(&contract.toolchain.rust, "toolchain.rust")?; + validate_non_empty(&contract.toolchain.coverage_rust, "toolchain.coverage_rust")?; validate_non_empty(&contract.toolchain.wasm_target, "toolchain.wasm_target")?; validate_non_empty(&contract.report.output, "report.output")?; validate_non_empty( @@ -161,6 +221,18 @@ pub(crate) fn validate_contract(contract: &CoverageContract) -> Result<(), Strin for path in &scope.paths { validate_non_empty(path, &format!("scopes.{name}.paths entry"))?; } + if scope.branches_applicable { + if scope.branch_reason.is_some() { + return Err(format!( + "scopes.{name}.branch_reason requires branches_applicable = false" + )); + } + } else { + validate_non_empty( + scope.branch_reason.as_deref().unwrap_or_default(), + &format!("scopes.{name}.branch_reason"), + )?; + } } if contract.exclusions.is_empty() { return Err("contracts/coverage.toml exclusions must not be empty".to_owned()); @@ -210,9 +282,9 @@ pub(crate) fn evaluate_report( .first() .ok_or_else(|| format!("{} did not include coverage data", report_path.display()))?; validate_metric( - "total lines", + "total executable lines", &data.totals.lines, - contract.policy.require_lines, + contract.policy.require_executable_lines, )?; validate_metric( "total functions", @@ -224,6 +296,11 @@ pub(crate) fn evaluate_report( &data.totals.regions, contract.policy.require_regions, )?; + validate_metric( + "total branches", + &data.totals.branches, + contract.policy.require_branches, + )?; let mut failures = Vec::new(); for (scope_name, scope) in &contract.scopes { let scope_summary = match scope_summary(root, data, scope) { @@ -236,10 +313,10 @@ pub(crate) fn evaluate_report( collect_scope_metric_failure( &mut failures, scope_name, - "lines", + "executable lines", &scope_summary.lines, scope.threshold, - contract.policy.require_lines, + contract.policy.require_executable_lines, ); collect_scope_metric_failure( &mut failures, @@ -257,6 +334,21 @@ pub(crate) fn evaluate_report( scope.threshold, contract.policy.require_regions, ); + if scope.branches_applicable { + collect_scope_metric_failure( + &mut failures, + scope_name, + "branches", + &scope_summary.branches, + scope.threshold, + contract.policy.require_branches, + ); + } else if scope_summary.branches.count != 0 { + failures.push(format!( + "coverage scope {scope_name}: declared branches inapplicable but measured {} branch records", + scope_summary.branches.count + )); + } } if !contract.policy.enforce { println!( @@ -294,13 +386,362 @@ fn scope_summary( scope.paths.join(", ") )); } - Ok(accumulator.summary()) + let mut summary = accumulator.summary(); + if !data.functions.is_empty() { + let (lines, functions, regions, branches) = normalized_source_metrics(root, data, scope)?; + summary.lines = lines; + summary.functions = functions; + summary.regions = regions; + if branches.count > 0 { + summary.branches = branches; + } + } + Ok(summary) +} + +fn normalized_source_metrics( + root: &Path, + data: &LlvmCovData, + scope: &CoverageScope, +) -> Result<(LlvmCovMetric, LlvmCovMetric, LlvmCovMetric, LlvmCovMetric), String> { + let mut groups = BTreeMap::<FunctionKey, Vec<&LlvmCovFunction>>::new(); + for function in &data.functions { + if function.filenames.is_empty() || function.regions.is_empty() { + continue; + } + let Some(definition) = function + .regions + .first() + .and_then(|region| region_key(region)) + else { + continue; + }; + groups + .entry(FunctionKey { + filenames: function.filenames.clone(), + definition, + }) + .or_default() + .push(function); + } + let mut function_count = 0_u64; + let mut function_covered = 0_u64; + let mut all_regions = BTreeMap::<(String, RegionKey), bool>::new(); + let mut lines = BTreeMap::<(String, u64), bool>::new(); + let mut branches = BTreeMap::<(String, BranchKey), (bool, bool)>::new(); + let mut source_cache = BTreeMap::<PathBuf, Option<CoverageSource>>::new(); + for variants in groups.values() { + let primary_definition = variants.iter().find_map(|function| { + let region = function.regions.first()?; + let filename = region_filename(function, region)?; + let relative = report_filename(root, filename); + if scope.paths.iter().any(|path| path_matches(path, &relative)) { + Some((filename, region[0])) + } else { + None + } + }); + let Some((primary, definition_line)) = primary_definition else { + continue; + }; + if variants.iter().all(|function| { + function.regions.first().is_some_and(|region| { + region + .first() + .is_some_and(|line| is_ignorable_source_line(primary, *line, &mut source_cache)) + }) + }) { + continue; + } + if is_authored_function_line(primary, definition_line, &mut source_cache) { + function_count += 1; + if variants.iter().any(|function| function.count > 0) { + function_covered += 1; + } + } + let mut regions = BTreeMap::<(String, RegionKey), bool>::new(); + for function in variants { + for region in &function.regions { + let filename = region_filename(function, region) + .expect("grouped coverage functions always retain a source filename"); + let relative = report_filename(root, filename); + if !scope.paths.iter().any(|path| path_matches(path, &relative)) { + continue; + } + let Some(key) = region_key(region) else { + continue; + }; + let covered = region.get(4).is_some_and(|count| *count > 0); + regions + .entry((filename.to_owned(), key)) + .and_modify(|existing| *existing |= covered) + .or_insert(covered); + } + } + for ((filename, region), covered) in regions { + if is_ignorable_source_line(&filename, region.line_start, &mut source_cache) { + continue; + } + let filename = normalized_path_string(Path::new(&filename)); + all_regions + .entry((filename.clone(), region.clone())) + .and_modify(|existing| *existing |= covered) + .or_insert(covered); + if region.kind == 0 { + for line in region.line_start..=region.line_end { + if !is_ignorable_source_line(&filename, line, &mut source_cache) { + lines + .entry((filename.clone(), line)) + .and_modify(|existing| *existing |= covered) + .or_insert(covered); + } + } + } + } + for function in variants { + for branch in &function.branches { + let filename = branch_filename(function, branch) + .expect("grouped coverage functions always retain a source filename"); + let relative = report_filename(root, filename); + if !scope.paths.iter().any(|path| path_matches(path, &relative)) { + continue; + } + let Some(key) = branch_key(branch) else { + continue; + }; + if is_ignorable_branch(filename, &key, &mut source_cache) { + continue; + } + let true_covered = branch.get(4).is_some_and(|count| *count > 0); + let false_covered = branch.get(5).is_some_and(|count| *count > 0); + let filename = normalized_path_string(Path::new(filename)); + branches + .entry((filename, key)) + .and_modify(|covered| { + covered.0 |= true_covered; + covered.1 |= false_covered; + }) + .or_insert((true_covered, false_covered)); + } + } + } + let line_count = lines.len() as u64; + let line_covered = lines.values().filter(|covered| **covered).count() as u64; + let branch_count = (branches.len() * 2) as u64; + let branch_covered = branches + .values() + .map(|covered| u64::from(covered.0) + u64::from(covered.1)) + .sum::<u64>(); + let region_count = all_regions.len() as u64; + let region_covered = all_regions.values().filter(|covered| **covered).count() as u64; + Ok(( + LlvmCovMetric { + count: line_count, + covered: line_covered, + percent: metric_percent(line_count, line_covered), + }, + LlvmCovMetric { + count: function_count, + covered: function_covered, + percent: metric_percent(function_count, function_covered), + }, + LlvmCovMetric { + count: region_count, + covered: region_covered, + percent: metric_percent(region_count, region_covered), + }, + LlvmCovMetric { + count: branch_count, + covered: branch_covered, + percent: metric_percent(branch_count, branch_covered), + }, + )) +} + +fn region_filename<'a>(function: &'a LlvmCovFunction, region: &[u64]) -> Option<&'a str> { + region + .get(5) + .and_then(|index| function.filenames.get(*index as usize)) + .or_else(|| function.filenames.first()) + .map(String::as_str) +} + +fn branch_filename<'a>(function: &'a LlvmCovFunction, branch: &[u64]) -> Option<&'a str> { + branch + .get(6) + .and_then(|index| function.filenames.get(*index as usize)) + .or_else(|| function.filenames.first()) + .map(String::as_str) +} + +fn is_authored_function_line( + filename: &str, + line: u64, + cache: &mut BTreeMap<PathBuf, Option<CoverageSource>>, +) -> bool { + let path = PathBuf::from(filename); + if !cache.contains_key(&path) { + let _ = is_ignorable_source_line(filename, line, cache); + } + cache + .get(&path) + .and_then(Option::as_ref) + .and_then(|source| source.raw.lines().nth(line.saturating_sub(1) as usize)) + .is_some_and(|source_line| source_line.contains("fn ")) +} + +fn region_key(region: &[u64]) -> Option<RegionKey> { + Some(RegionKey { + line_start: *region.first()?, + column_start: *region.get(1)?, + line_end: *region.get(2)?, + column_end: *region.get(3)?, + kind: *region.get(7)?, + }) +} + +fn branch_key(branch: &[u64]) -> Option<BranchKey> { + Some(BranchKey { + line_start: *branch.first()?, + column_start: *branch.get(1)?, + line_end: *branch.get(2)?, + column_end: *branch.get(3)?, + kind: *branch.get(8)?, + }) +} + +fn is_ignorable_source_line( + filename: &str, + line: u64, + cache: &mut BTreeMap<PathBuf, Option<CoverageSource>>, +) -> bool { + let path = PathBuf::from(filename); + let source = cache.entry(path.clone()).or_insert_with(|| { + fs::read_to_string(path).ok().map(|raw| CoverageSource { + raw: raw.clone(), + cfg_test_lines: annotated_source_lines(&raw, "cfg(test)"), + coverage_off_lines: annotated_source_lines( + &raw, + "cfg_attr(coverage_nightly, coverage(off))", + ), + }) + }); + let Some(source) = source else { + return false; + }; + line.checked_sub(1) + .map(|index| { + let index = index as usize; + source.cfg_test_lines.get(index).copied().unwrap_or(false) + || source + .coverage_off_lines + .get(index) + .copied() + .unwrap_or(false) + }) + .unwrap_or(false) +} + +fn is_ignorable_branch( + filename: &str, + branch: &BranchKey, + cache: &mut BTreeMap<PathBuf, Option<CoverageSource>>, +) -> bool { + if is_ignorable_source_line(filename, branch.line_start, cache) { + return true; + } + if branch.line_start != branch.line_end { + return false; + } + let path = PathBuf::from(filename); + let Some(Some(source)) = cache.get(&path) else { + return false; + }; + let Some(line) = source + .raw + .lines() + .nth(branch.line_start.saturating_sub(1) as usize) + else { + return false; + }; + let start = branch.column_start.saturating_sub(1) as usize; + let end = branch.column_end.saturating_sub(1) as usize; + let slice = line.get(start..end); + (branch.column_end == branch.column_start + 1 && slice == Some("?")) + || line.contains("unreachable!()") + || (line.contains("assert!(matches!(") && slice == Some("matches!")) +} + +fn annotated_source_lines(source: &str, marker: &str) -> Vec<bool> { + let mut pending = false; + let mut depth = None; + let mut lines = Vec::with_capacity(source.lines().count()); + for line in source.lines() { + let trimmed = line.trim(); + let mut annotated = depth.is_some(); + let marker_matches = if marker == "cfg(test)" { + trimmed.starts_with("#[cfg(test)]") || trimmed.starts_with("#[cfg(all(test,") + } else { + trimmed.contains(marker) + }; + if depth.is_none() && marker_matches { + pending = true; + annotated = true; + } else if depth.is_none() && pending { + annotated = true; + let content = + !trimmed.is_empty() && !trimmed.starts_with("//") && !trimmed.starts_with("#["); + if content { + let delta = brace_delta(trimmed); + if delta > 0 { + depth = Some(0_i64); + pending = false; + } else if trimmed.contains('{') || trimmed.ends_with(';') { + pending = false; + } + } + } + lines.push(annotated); + if let Some(current) = depth.as_mut() { + *current += brace_delta(trimmed); + if *current <= 0 { + depth = None; + } + } + } + lines +} + +fn brace_delta(line: &str) -> i64 { + line.bytes().filter(|byte| *byte == b'{').count() as i64 + - line.bytes().filter(|byte| *byte == b'}').count() as i64 } fn report_filename(root: &Path, filename: &str) -> String { - let path = Path::new(filename); - let relative = path.strip_prefix(root).unwrap_or(path); - relative.to_string_lossy().replace('\\', "/") + let path = normalize_path(Path::new(filename)); + let root = normalize_path(root); + let relative = path.strip_prefix(&root).unwrap_or(&path); + normalized_path_string(relative) +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + let _ = normalized.pop(); + } + Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + } + } + normalized +} + +fn normalized_path_string(path: &Path) -> String { + normalize_path(path).to_string_lossy().replace('\\', "/") } fn path_matches(pattern: &str, path: &str) -> bool { @@ -342,8 +783,8 @@ fn validate_metric(name: &str, metric: &LlvmCovMetric, required: bool) -> Result fn enforce_metric(name: &str, metric: &LlvmCovMetric, threshold: f64) -> Result<(), String> { if metric.percent < threshold { return Err(format!( - "coverage {name} {:.3}% is below required {:.1}%", - metric.percent, threshold + "coverage {name} {:.3}% ({}/{}) is below required {:.1}%", + metric.percent, metric.covered, metric.count, threshold )); } Ok(()) diff --git a/tools/sdk_xtask_import/src/coverage_policy_tests.rs b/tools/sdk_xtask_import/src/coverage_policy_tests.rs @@ -5,8 +5,11 @@ use std::{ }; use super::{ - CoverageContract, LlvmCovMetric, enforce_metric, evaluate_report, metric_percent, path_matches, - validate_contract, validate_metric, + BranchKey, CoverageContract, LlvmCovFunction, LlvmCovMetric, LlvmCovReport, + annotated_source_lines, brace_delta, branch_filename, branch_key, enforce_metric, + evaluate_report, is_authored_function_line, is_ignorable_branch, is_ignorable_source_line, + metric_percent, normalized_source_metrics, path_matches, region_filename, region_key, + report_filename, validate_contract, validate_metric, }; const CONTRACT: &str = r#" @@ -14,10 +17,12 @@ const CONTRACT: &str = r#" enforce = true require_regions = true require_functions = true -require_lines = true +require_executable_lines = true +require_branches = true [toolchain] -rust = "1.97.0" +rust = "1.97.1" +coverage_rust = "nightly" wasm_target = "wasm32-unknown-unknown" [report] @@ -31,7 +36,7 @@ wasm_glue = "wasm glue is checked through package validation" [scopes.xtask_policy] paths = ["tools/xtask/src/coverage_policy.rs"] -threshold = 100.0 +threshold = 90.0 [exclusions.generated] paths = ["packages/*/src/generated/**"] @@ -43,6 +48,7 @@ struct Metrics { lines: (u64, u64, f64), functions: (u64, u64, f64), regions: (u64, u64, f64), + branches: (u64, u64, f64), } fn covered() -> Metrics { @@ -50,6 +56,7 @@ fn covered() -> Metrics { lines: (100, 100, 100.0), functions: (50, 50, 100.0), regions: (200, 200, 100.0), + branches: (80, 80, 100.0), } } @@ -81,10 +88,11 @@ fn metric_json(metric: (u64, u64, f64)) -> String { fn summary_json(metrics: Metrics) -> String { format!( - r#"{{"lines":{},"functions":{},"regions":{}}}"#, + r#"{{"lines":{},"functions":{},"regions":{},"branches":{}}}"#, metric_json(metrics.lines), metric_json(metrics.functions), - metric_json(metrics.regions) + metric_json(metrics.regions), + metric_json(metrics.branches) ) } @@ -117,7 +125,12 @@ fn validates_contract_shape() { #[test] fn rejects_blank_contract_fields() { let cases = [ - ("rust = \"1.97.0\"", "rust = \" \"", "toolchain.rust"), + ("rust = \"1.97.1\"", "rust = \" \"", "toolchain.rust"), + ( + "coverage_rust = \"nightly\"", + "coverage_rust = \" \"", + "toolchain.coverage_rust", + ), ( "wasm_target = \"wasm32-unknown-unknown\"", "wasm_target = \" \"", @@ -198,11 +211,25 @@ fn rejects_contract_collection_errors() { "exclusion name", ), ( - CONTRACT.replace("threshold = 100.0", "threshold = 101.0"), + CONTRACT.replace("threshold = 90.0", "threshold = 101.0"), "scopes.xtask_policy.threshold", ), ( CONTRACT.replace( + "threshold = 90.0", + "threshold = 90.0\nbranch_reason = \"not applicable\"", + ), + "branch_reason requires branches_applicable = false", + ), + ( + CONTRACT.replace( + "threshold = 90.0", + "threshold = 90.0\nbranches_applicable = false", + ), + "scopes.xtask_policy.branch_reason", + ), + ( + CONTRACT.replace( "paths = [\"tools/xtask/src/coverage_policy.rs\"]", "paths = []", ), @@ -246,7 +273,7 @@ fn accepts_passing_reports_and_rejects_undercovered_scopes() { evaluate_report(&root, &report_path, &contract(CONTRACT)).expect("passing report"); let mut undercovered = covered(); - undercovered.lines = (100, 99, 99.0); + undercovered.lines = (100, 89, 89.0); let failing_report = report_json(&filename, undercovered, covered()); fs::write(&report_path, failing_report).expect("write failing report"); let error = evaluate_report(&root, &report_path, &contract(CONTRACT)) @@ -263,6 +290,7 @@ fn disabled_enforcement_accepts_measured_undercoverage() { undercovered.lines = (100, 0, 0.0); undercovered.functions = (50, 0, 0.0); undercovered.regions = (200, 0, 0.0); + undercovered.branches = (80, 0, 0.0); let report_path = write_report(&root, &report_json(&filename, undercovered, undercovered)); let raw = CONTRACT.replace("enforce = true", "enforce = false"); evaluate_report(&root, &report_path, &contract(&raw)).expect("disabled policy passes"); @@ -270,6 +298,88 @@ fn disabled_enforcement_accepts_measured_undercoverage() { } #[test] +fn branch_inapplicability_is_explicit_and_rejects_measured_branches() { + let root = test_root("branch_inapplicability"); + let filename = scope_file(&root); + let raw = CONTRACT.replace( + "threshold = 90.0", + "threshold = 90.0\nbranches_applicable = false\nbranch_reason = \"straight-line source\"", + ); + let mut no_branches = covered(); + no_branches.branches = (0, 0, 0.0); + let report_path = write_report(&root, &report_json(&filename, no_branches, covered())); + evaluate_report(&root, &report_path, &contract(&raw)).expect("no-branch scope passes"); + + fs::write(&report_path, report_json(&filename, covered(), covered())) + .expect("write measured branches"); + let error = evaluate_report(&root, &report_path, &contract(&raw)) + .expect_err("measured branch drift rejected"); + assert!(error.contains("declared branches inapplicable"), "{error}"); + fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn duplicate_harness_variants_are_merged_and_test_modules_are_excluded() { + let root = test_root("duplicate_harness_variants"); + let filename = scope_file(&root); + let source_path = Path::new(&filename); + fs::create_dir_all(source_path.parent().expect("source parent")).expect("create source parent"); + fs::write( + source_path, + "pub fn production() -> bool { true }\n\n#[cfg(test)]\nmod tests {\n fn helper() -> bool { false }\n}\n", + ) + .expect("write source"); + let report = format!( + r#"{{"data":[{{"files":[{{"filename":"{filename}","summary":{summary}}}],"functions":[{{"count":0,"filenames":["{filename}"],"regions":[[1,1,1,37,0,0,0,0]]}},{{"count":1,"filenames":["{filename}"],"regions":[[1,1,1,37,1,0,0,0]]}},{{"count":0,"filenames":["{filename}"],"regions":[[5,5,5,34,0,0,0,0]]}}],"totals":{summary}}}]}}"#, + summary = summary_json(covered()), + ); + let report_path = write_report(&root, &report); + + evaluate_report(&root, &report_path, &contract(CONTRACT)) + .expect("normalized production coverage passes"); + fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn normalized_details_fail_closed_and_merge_every_supported_record_shape() { + let root = test_root("normalized_record_shapes"); + let filename = scope_file(&root); + let source_path = Path::new(&filename); + fs::create_dir_all(source_path.parent().expect("source parent")).expect("source parent"); + fs::write( + source_path, + "pub fn production(flag: bool) -> bool { if flag { true } else { false } }\nfn uncovered() {}\nconst VALUE: bool = true;\n#[cfg(test)]\nmod tests { fn ignored() {} }\n", + ) + .expect("source"); + let outside = root.join("outside.rs").display().to_string(); + fs::write(&outside, "fn outside() {}\n").expect("outside source"); + let summary = summary_json(covered()); + let report = format!( + r#"{{"data":[{{"files":[],"functions":[ + {{"count":0,"filenames":[],"regions":[],"branches":[]}}, + {{"count":0,"filenames":["{filename}"],"regions":[[1,1,1]],"branches":[]}}, + {{"count":0,"filenames":["{outside}"],"regions":[[1,1,1,16,0,0,0,0]],"branches":[]}}, + {{"count":0,"filenames":["{filename}"],"regions":[[2,1,2,18,0,0,0,0]],"branches":[]}}, + {{"count":1,"filenames":["{filename}"],"regions":[[3,1,3,25,1,0,0,0]],"branches":[]}}, + {{"count":1,"filenames":["{filename}","{outside}"],"regions":[[1,1,1,76,1,0,0,0],[1,1,5,36,1,0,0,0],[2,1,2,18,0,0,0,1],[4,1,4,13,0,0,0,0],[1,1,1,2,0,1,0,0],[1]],"branches":[[1,40,1,47,1,0,0,0,4],[2,1,2,3,0,1,0,0,4],[4,1,4,3,0,0,0,0,4],[99,1,99,3,0,0,0,0,4],[1,1,1,2,0,0,1,0,4],[1]]}}, + {{"count":0,"filenames":["{filename}","{outside}"],"regions":[[1,1,1,76,0,0,0,0],[2,1,2,18,1,0,0,0]],"branches":[[1,40,1,47,0,1,0,0,4],[2,1,2,3,1,0,0,0,4]]}} + ],"totals":{summary}}}]}}"#, + ); + let parsed = serde_json::from_str::<LlvmCovReport>(&report).expect("detail report"); + let policy = contract(CONTRACT); + let scope = policy.scopes.get("xtask_policy").expect("scope"); + let (lines, functions, regions, branches) = + normalized_source_metrics(&root, &parsed.data[0], scope).expect("normalized details"); + assert!(lines.count >= 2); + assert_eq!(functions.count, 2); + assert_eq!(functions.covered, 1); + assert!(regions.count >= 2); + assert_eq!(branches.count, 6); + assert_eq!(branches.covered, 4); + fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] fn rejects_unreadable_malformed_and_empty_reports() { let root = test_root("bad_reports"); let missing = root.join("missing.json"); @@ -305,6 +415,11 @@ fn rejects_required_total_metric_failures() { }, { totals = covered(); + totals.branches = (0, 0, 0.0); + totals + }, + { + totals = covered(); totals.lines = (1, 2, 200.0); totals }, @@ -380,3 +495,167 @@ fn metric_helpers_cover_edges() { assert_eq!(metric_percent(0, 0), 0.0); assert_eq!(metric_percent(4, 2), 50.0); } + +#[test] +fn detail_key_and_path_helpers_cover_valid_and_short_records() { + assert!(region_key(&[1, 2, 3, 4, 5, 6, 7, 8]).is_some()); + assert!(region_key(&[1, 2, 3]).is_none()); + assert!(branch_key(&[1, 2, 3, 4, 5, 6, 7, 8, 9]).is_some()); + assert!(branch_key(&[1, 2, 3]).is_none()); + assert_eq!(brace_delta("fn value() { if true { 1 } else { 0 } }"), 0); + assert_eq!(brace_delta("{{"), 2); + assert_eq!(brace_delta("}"), -1); + + let root = Path::new("/workspace/sdk"); + assert_eq!( + report_filename(root, "/workspace/sdk/crates/sdk/src/lib.rs"), + "crates/sdk/src/lib.rs" + ); + assert_eq!( + report_filename(root, "/elsewhere/lib.rs"), + "/elsewhere/lib.rs" + ); + assert_eq!( + report_filename( + root, + "/workspace/sdk/crates/sdk/src/adapters/../../tests/unit.rs" + ), + "crates/sdk/tests/unit.rs" + ); +} + +#[test] +fn detail_filename_helpers_honor_record_indexes_and_safe_fallbacks() { + let function = LlvmCovFunction { + count: 1, + filenames: vec!["primary.rs".to_owned(), "expanded.rs".to_owned()], + regions: Vec::new(), + branches: Vec::new(), + }; + assert_eq!( + region_filename(&function, &[1, 1, 1, 2, 1, 1, 0, 0]), + Some("expanded.rs") + ); + assert_eq!( + region_filename(&function, &[1, 1, 1, 2, 1, 99, 0, 0]), + Some("primary.rs") + ); + assert_eq!(region_filename(&function, &[1]), Some("primary.rs")); + assert_eq!( + branch_filename(&function, &[1, 1, 1, 2, 1, 0, 1, 0, 4]), + Some("expanded.rs") + ); + assert_eq!( + branch_filename(&function, &[1, 1, 1, 2, 1, 0, 99, 0, 4]), + Some("primary.rs") + ); + assert_eq!(branch_filename(&function, &[1]), Some("primary.rs")); + + let no_filenames = LlvmCovFunction { + count: 0, + filenames: Vec::new(), + regions: Vec::new(), + branches: Vec::new(), + }; + assert_eq!(region_filename(&no_filenames, &[1]), None); + assert_eq!(branch_filename(&no_filenames, &[1]), None); +} + +#[test] +fn authored_and_annotated_source_helpers_cover_marker_shapes() { + let root = test_root("source_helpers"); + let source = root.join("source.rs"); + fs::write( + &source, + "pub fn production() {}\n#[cfg(test)]\nmod tests {\n fn helper() {}\n}\n#[cfg(all(test, feature = \"x\"))]\nfn gated() {}\n#[cfg_attr(coverage_nightly, coverage(off))]\nfn excluded() {}\n", + ) + .expect("source"); + let filename = source.display().to_string(); + let mut cache = std::collections::BTreeMap::new(); + + assert!(is_authored_function_line(&filename, 1, &mut cache)); + assert!(!is_authored_function_line(&filename, 2, &mut cache)); + assert!(!is_ignorable_source_line(&filename, 1, &mut cache)); + assert!(is_ignorable_source_line(&filename, 2, &mut cache)); + assert!(is_ignorable_source_line(&filename, 4, &mut cache)); + assert!(is_ignorable_source_line(&filename, 7, &mut cache)); + assert!(is_ignorable_source_line(&filename, 9, &mut cache)); + assert!(!is_ignorable_source_line(&filename, 0, &mut cache)); + assert!(!is_ignorable_source_line( + root.join("missing.rs").to_str().expect("path"), + 1, + &mut cache + )); + + let marked = annotated_source_lines( + "#[cfg(test)]\n// note\nfn test() {}\nfn live() {}", + "cfg(test)", + ); + assert_eq!(marked, vec![true, true, true, false]); + let coverage_off = annotated_source_lines( + "#[cfg_attr(coverage_nightly, coverage(off))]\n#[inline]\nfn excluded() {\n if true {\n work();\n }\n}\nfn live() {}", + "cfg_attr(coverage_nightly, coverage(off))", + ); + assert_eq!( + coverage_off, + vec![true, true, true, true, true, true, true, false] + ); + assert_eq!( + annotated_source_lines("#[cfg(test)]\nfn declaration();\nfn live() {}", "cfg(test)"), + vec![true, true, false] + ); + fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn branch_filter_only_excludes_explicit_synthetic_constructs() { + let root = test_root("branch_helpers"); + let source = root.join("source.rs"); + fs::write( + &source, + "fn value() {\n let _ = call()?;\n unreachable!();\n assert!(matches!(value, Some(_)));\n if live { work(); }\n}\n", + ) + .expect("source"); + let filename = source.display().to_string(); + let mut cache = std::collections::BTreeMap::new(); + assert!(!is_ignorable_source_line(&filename, 1, &mut cache)); + + let branch = |line_start, column_start, line_end, column_end| BranchKey { + line_start, + column_start, + line_end, + column_end, + kind: 4, + }; + assert!(is_ignorable_branch( + &filename, + &branch(2, 16, 2, 17), + &mut cache + )); + assert!(is_ignorable_branch( + &filename, + &branch(3, 2, 3, 10), + &mut cache + )); + assert!(is_ignorable_branch( + &filename, + &branch(4, 10, 4, 18), + &mut cache + )); + assert!(!is_ignorable_branch( + &filename, + &branch(5, 2, 5, 9), + &mut cache + )); + assert!(!is_ignorable_branch( + &filename, + &branch(5, 2, 6, 1), + &mut cache + )); + assert!(!is_ignorable_branch( + root.join("missing.rs").to_str().expect("path"), + &branch(1, 1, 1, 2), + &mut cache + )); + fs::remove_dir_all(root).expect("cleanup"); +} diff --git a/tools/sdk_xtask_import/src/main.rs b/tools/sdk_xtask_import/src/main.rs @@ -130,7 +130,7 @@ fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> { } fn usage() -> String { - "usage: cargo xtask architecture | cargo xtask architecture-ci | cargo xtask check-api-boundaries | cargo xtask check-dependency-boundaries | cargo xtask generate | cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate bindings <swift|kotlin> | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke facade-rust-local | sdk-rust-local | front-doors-rust-local | cargo xtask coverage run | cargo xtask release qualify-features | cargo xtask release qualify-api | cargo xtask release qualify-portable | cargo xtask release qualify-supply-chain | cargo xtask release qualify-targets" + "usage: cargo xtask architecture | cargo xtask architecture-ci | cargo xtask check-api-boundaries | cargo xtask check-dependency-boundaries | cargo xtask generate | cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate bindings <swift|kotlin> | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke facade-rust-local | sdk-rust-local | front-doors-rust-local | cargo xtask coverage run | cargo xtask coverage check | cargo xtask release qualify-features | cargo xtask release qualify-api | cargo xtask release qualify-portable | cargo xtask release qualify-supply-chain | cargo xtask release qualify-targets" .to_owned() }