lib

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

commit ef9b2138adebd0e620184e122d725517edd55209
parent 89f5bf05516d24cd57007e676e5bd8c8889e5325
Author: triesap <tyson@radroots.org>
Date:   Mon,  6 Jul 2026 21:04:38 +0000

transport_reticulum: add preview transport

- add deterministic Reticulum preview profile and status model

- reject delivery attempts by default and defer only by explicit behavior

- keep deferred transport outcomes terminal but unsatisfied

Diffstat:
MCargo.lock | 9+++++++++
MCargo.toml | 2++
Mcontracts/coverage.toml | 1+
Mcontracts/manifest.toml | 1+
Mcrates/transport/src/status.rs | 2+-
Mcrates/transport/tests/transport.rs | 30+++++++++++++++++++++++++++---
Acrates/transport_reticulum/Cargo.toml | 26++++++++++++++++++++++++++
Acrates/transport_reticulum/README | 3+++
Acrates/transport_reticulum/src/lib.rs | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/transport_reticulum/tests/reticulum_preview.rs | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 559 insertions(+), 4 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4876,6 +4876,15 @@ dependencies = [ ] [[package]] +name = "radroots_transport_reticulum" +version = "0.1.0-alpha.2" +dependencies = [ + "radroots_transport", + "serde", + "serde_json", +] + +[[package]] name = "radroots_types" version = "0.1.0-alpha.2" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/outbox", "crates/publish_proxy_protocol", "crates/transport_nostr", + "crates/transport_reticulum", "crates/runtime", "crates/secret_vault", "crates/simplex_app_store", @@ -87,6 +88,7 @@ radroots_nostr_runtime = { path = "crates/nostr_runtime", version = "0.1.0-alpha radroots_outbox = { path = "crates/outbox", version = "0.1.0-alpha.2", default-features = false } radroots_publish_proxy_protocol = { path = "crates/publish_proxy_protocol", version = "0.1.0-alpha.2", default-features = false } radroots_transport_nostr = { path = "crates/transport_nostr", version = "0.1.0-alpha.2", default-features = false } +radroots_transport_reticulum = { path = "crates/transport_reticulum", version = "0.1.0-alpha.2", default-features = false } radroots_simplex_agent_proto = { path = "crates/simplex_agent_proto", version = "0.1.0-alpha.2", default-features = false } radroots_simplex_agent_runtime = { path = "crates/simplex_agent_runtime", version = "0.1.0-alpha.2", default-features = false } radroots_simplex_agent_store = { path = "crates/simplex_agent_store", version = "0.1.0-alpha.2", default-features = false } diff --git a/contracts/coverage.toml b/contracts/coverage.toml @@ -54,6 +54,7 @@ crates = [ "radroots_protected_store", "radroots_publish_proxy_protocol", "radroots_transport_nostr", + "radroots_transport_reticulum", "radroots_replica_db", "radroots_replica_db_schema", "radroots_replica_sync", diff --git a/contracts/manifest.toml b/contracts/manifest.toml @@ -46,6 +46,7 @@ deferred_publication = [ "radroots_outbox", "radroots_publish_proxy_protocol", "radroots_transport_nostr", + "radroots_transport_reticulum", "radroots_transport", "radroots_mesh", "radroots_mesh_agent_proto", diff --git a/crates/transport/src/status.rs b/crates/transport/src/status.rs @@ -17,7 +17,7 @@ impl RadrootsTransportDeliveryTargetStatus { } pub fn counts_as_satisfied(self) -> bool { - matches!(self, Self::Accepted | Self::Deferred) + matches!(self, Self::Accepted) } } diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -1,7 +1,9 @@ use radroots_transport::{ - RadrootsTransportDeliveryRequest, RadrootsTransportError, RadrootsTransportKind, - RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, - RadrootsTransportTargetFingerprint, RadrootsTransportTargetSet, RadrootsTransportTargetUri, + RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, + RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportKind, + RadrootsTransportOutcome, RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, + RadrootsTransportTargetFingerprint, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, + RadrootsTransportTargetUri, }; #[test] @@ -91,6 +93,28 @@ fn satisfaction_policy_counts_target_statuses() { } #[test] +fn deferred_transport_outcomes_are_terminal_but_not_satisfied() { + let target = + RadrootsTransportTarget::new(RadrootsTransportKind::Reticulum, "reticulum:preview") + .expect("target"); + let receipt = RadrootsTransportDeliveryReceipt { + request_id: "reticulum-preview".to_owned(), + target_receipts: vec![RadrootsTransportTargetReceipt::new( + target, + RadrootsTransportOutcome::new(RadrootsTransportDeliveryTargetStatus::Deferred), + )], + }; + + assert!(RadrootsTransportDeliveryTargetStatus::Deferred.is_terminal()); + assert_eq!(receipt.satisfied_target_count(), 0); + assert!( + !RadrootsTransportSatisfactionPolicy::AnyTarget + .is_satisfied_by(1, receipt.satisfied_target_count()) + .expect("satisfaction check") + ); +} + +#[test] fn request_models_round_trip_with_serde() { let target = RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example") .expect("target"); diff --git a/crates/transport_reticulum/Cargo.toml b/crates/transport_reticulum/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "radroots_transport_reticulum" +publish = false +version = "0.1.0-alpha.2" +edition.workspace = true +authors = ["Tyson Lupul <tyson@radroots.org>"] +rust-version.workspace = true +license.workspace = true +description = "Reticulum preview transport layer for Radroots" +repository.workspace = true +homepage.workspace = true +readme = "README" + +[features] +default = ["serde"] +serde = ["dep:serde", "radroots_transport/serde"] + +[dependencies] +radroots_transport = { workspace = true, default-features = false } +serde = { workspace = true, optional = true, features = ["derive"] } + +[dev-dependencies] +serde_json = { workspace = true, features = ["std"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/transport_reticulum/README b/crates/transport_reticulum/README @@ -0,0 +1,3 @@ +# radroots_transport_reticulum + +Deterministic Reticulum preview transport substrate for unavailable delivery, deferred delivery-plan modeling, and non-networked fetch status. diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs @@ -0,0 +1,312 @@ +#![no_std] +#![forbid(unsafe_code)] + +extern crate alloc; + +use alloc::borrow::ToOwned; +use alloc::string::String; +use alloc::vec::Vec; +use core::fmt; +use radroots_transport::{ + RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, + RadrootsTransportDeliveryTargetStatus, RadrootsTransportImplementationState, + RadrootsTransportKind, RadrootsTransportOutcome, RadrootsTransportTarget, + RadrootsTransportTargetReceipt, +}; + +const DEFAULT_PROFILE_ID: &str = "transport.reticulum.preview"; +const DEFAULT_ENDPOINT_URI: &str = "reticulum:preview-unavailable"; +const UNAVAILABLE_CODE: &str = "transport_unavailable"; +const DEFERRED_CODE: &str = "deferred_until_implemented"; +const PREVIEW_UNAVAILABLE_MESSAGE: &str = + "Reticulum preview transport is configured but unavailable"; +const DEFERRED_MESSAGE: &str = "Reticulum preview delivery is deferred until implementation"; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsReticulumPreviewBehavior { + RejectDeliveryAttempts, + DeferDeliveryPlans, +} + +impl Default for RadrootsReticulumPreviewBehavior { + fn default() -> Self { + Self::RejectDeliveryAttempts + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsReticulumPreviewReadiness { + PreviewUnavailable, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsReticulumPreviewEndpoint { + uri: String, +} + +impl RadrootsReticulumPreviewEndpoint { + pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsReticulumPreviewError> { + let uri = raw.as_ref().trim().to_ascii_lowercase(); + if uri.is_empty() || !uri.starts_with("reticulum:") || uri == "reticulum:" { + return Err(RadrootsReticulumPreviewError::InvalidEndpoint); + } + if uri + .chars() + .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace()) + { + return Err(RadrootsReticulumPreviewError::InvalidEndpoint); + } + Ok(Self { uri }) + } + + pub fn as_str(&self) -> &str { + self.uri.as_str() + } + + pub fn into_string(self) -> String { + self.uri + } +} + +impl Default for RadrootsReticulumPreviewEndpoint { + fn default() -> Self { + Self::parse(DEFAULT_ENDPOINT_URI).expect("default Reticulum preview endpoint") + } +} + +impl fmt::Display for RadrootsReticulumPreviewEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.uri.as_str()) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsReticulumPreviewProfile { + profile_id: String, + endpoint: RadrootsReticulumPreviewEndpoint, + behavior: RadrootsReticulumPreviewBehavior, +} + +impl RadrootsReticulumPreviewProfile { + pub fn new( + profile_id: impl Into<String>, + endpoint: RadrootsReticulumPreviewEndpoint, + behavior: RadrootsReticulumPreviewBehavior, + ) -> Result<Self, RadrootsReticulumPreviewError> { + let profile_id = profile_id.into(); + if profile_id.trim().is_empty() || profile_id.chars().any(char::is_whitespace) { + return Err(RadrootsReticulumPreviewError::InvalidProfileId); + } + Ok(Self { + profile_id, + endpoint, + behavior, + }) + } + + pub fn preview_unavailable() -> Self { + Self { + profile_id: DEFAULT_PROFILE_ID.to_owned(), + endpoint: RadrootsReticulumPreviewEndpoint::default(), + behavior: RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts, + } + } + + pub fn with_behavior(mut self, behavior: RadrootsReticulumPreviewBehavior) -> Self { + self.behavior = behavior; + self + } + + pub fn profile_id(&self) -> &str { + self.profile_id.as_str() + } + + pub fn endpoint(&self) -> &RadrootsReticulumPreviewEndpoint { + &self.endpoint + } + + pub fn behavior(&self) -> RadrootsReticulumPreviewBehavior { + self.behavior + } + + pub fn status(&self) -> RadrootsReticulumPreviewStatus { + RadrootsReticulumPreviewStatus { + profile_id: self.profile_id.clone(), + endpoint_uri: self.endpoint.as_str().to_owned(), + behavior: self.behavior, + readiness: RadrootsReticulumPreviewReadiness::PreviewUnavailable, + implementation_state: RadrootsTransportImplementationState::PreviewUnavailable, + } + } +} + +impl Default for RadrootsReticulumPreviewProfile { + fn default() -> Self { + Self::preview_unavailable() + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsReticulumPreviewStatus { + pub profile_id: String, + pub endpoint_uri: String, + pub behavior: RadrootsReticulumPreviewBehavior, + pub readiness: RadrootsReticulumPreviewReadiness, + pub implementation_state: RadrootsTransportImplementationState, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsReticulumPreviewTransport { + profile: RadrootsReticulumPreviewProfile, +} + +impl RadrootsReticulumPreviewTransport { + pub fn new(profile: RadrootsReticulumPreviewProfile) -> Self { + Self { profile } + } + + pub fn profile(&self) -> &RadrootsReticulumPreviewProfile { + &self.profile + } + + pub fn status(&self) -> RadrootsReticulumPreviewStatus { + self.profile.status() + } + + pub fn deliver( + &self, + request: RadrootsTransportDeliveryRequest, + ) -> Result<RadrootsTransportDeliveryReceipt, RadrootsReticulumPreviewError> { + ensure_reticulum_targets(request.target_set.targets())?; + let outcome = preview_outcome(self.profile.behavior); + let target_receipts = request + .target_set + .targets() + .iter() + .cloned() + .map(|target| RadrootsTransportTargetReceipt::new(target, outcome.clone())) + .collect::<Vec<_>>(); + Ok(RadrootsTransportDeliveryReceipt { + request_id: request.request_id, + target_receipts, + }) + } + + pub fn fetch( + &self, + request: RadrootsReticulumPreviewFetchRequest, + ) -> Result<RadrootsReticulumPreviewFetchReceipt, RadrootsReticulumPreviewError> { + if request.max_events == 0 { + return Err(RadrootsReticulumPreviewError::InvalidFetchLimit); + } + Ok(RadrootsReticulumPreviewFetchReceipt { + request_id: request.request_id, + endpoint_uri: self.profile.endpoint.as_str().to_owned(), + outcome: preview_outcome(self.profile.behavior), + observed_event_count: 0, + implementation_state: RadrootsTransportImplementationState::PreviewUnavailable, + }) + } +} + +impl Default for RadrootsReticulumPreviewTransport { + fn default() -> Self { + Self::new(RadrootsReticulumPreviewProfile::default()) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsReticulumPreviewFetchRequest { + pub request_id: String, + pub max_events: u16, +} + +impl RadrootsReticulumPreviewFetchRequest { + pub fn new( + request_id: impl Into<String>, + max_events: u16, + ) -> Result<Self, RadrootsReticulumPreviewError> { + if max_events == 0 { + return Err(RadrootsReticulumPreviewError::InvalidFetchLimit); + } + Ok(Self { + request_id: request_id.into(), + max_events, + }) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsReticulumPreviewFetchReceipt { + pub request_id: String, + pub endpoint_uri: String, + pub outcome: RadrootsTransportOutcome, + pub observed_event_count: usize, + pub implementation_state: RadrootsTransportImplementationState, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsReticulumPreviewError { + InvalidEndpoint, + InvalidProfileId, + InvalidFetchLimit, + NonReticulumTarget, +} + +impl fmt::Display for RadrootsReticulumPreviewError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::InvalidEndpoint => "invalid Reticulum preview endpoint", + Self::InvalidProfileId => "invalid Reticulum preview profile id", + Self::InvalidFetchLimit => "Reticulum preview fetch limit must be greater than zero", + Self::NonReticulumTarget => { + "Reticulum preview transport received a non-Reticulum target" + } + }) + } +} + +fn ensure_reticulum_targets( + targets: &[RadrootsTransportTarget], +) -> Result<(), RadrootsReticulumPreviewError> { + if targets + .iter() + .all(|target| target.kind == RadrootsTransportKind::Reticulum) + { + return Ok(()); + } + Err(RadrootsReticulumPreviewError::NonReticulumTarget) +} + +fn preview_outcome(behavior: RadrootsReticulumPreviewBehavior) -> RadrootsTransportOutcome { + let mut outcome = match behavior { + RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts => { + RadrootsTransportOutcome::new(RadrootsTransportDeliveryTargetStatus::Unavailable) + } + RadrootsReticulumPreviewBehavior::DeferDeliveryPlans => { + RadrootsTransportOutcome::new(RadrootsTransportDeliveryTargetStatus::Deferred) + } + }; + outcome.code = Some( + match behavior { + RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts => UNAVAILABLE_CODE, + RadrootsReticulumPreviewBehavior::DeferDeliveryPlans => DEFERRED_CODE, + } + .to_owned(), + ); + outcome.message = Some( + match behavior { + RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts => PREVIEW_UNAVAILABLE_MESSAGE, + RadrootsReticulumPreviewBehavior::DeferDeliveryPlans => DEFERRED_MESSAGE, + } + .to_owned(), + ); + outcome +} diff --git a/crates/transport_reticulum/tests/reticulum_preview.rs b/crates/transport_reticulum/tests/reticulum_preview.rs @@ -0,0 +1,177 @@ +use radroots_transport::{ + RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, + RadrootsTransportImplementationState, RadrootsTransportKind, + RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetSet, +}; +use radroots_transport_reticulum::{ + RadrootsReticulumPreviewBehavior, RadrootsReticulumPreviewEndpoint, + RadrootsReticulumPreviewError, RadrootsReticulumPreviewFetchRequest, + RadrootsReticulumPreviewProfile, RadrootsReticulumPreviewReadiness, + RadrootsReticulumPreviewTransport, +}; + +fn reticulum_target(uri: &str) -> RadrootsTransportTarget { + RadrootsTransportTarget::new(RadrootsTransportKind::Reticulum, uri).expect("reticulum target") +} + +fn nostr_target() -> RadrootsTransportTarget { + RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example") + .expect("nostr target") +} + +fn delivery_request(targets: Vec<RadrootsTransportTarget>) -> RadrootsTransportDeliveryRequest { + RadrootsTransportDeliveryRequest::new( + "reticulum-preview-delivery", + "sha256:preview-payload", + RadrootsTransportTargetSet::new(targets).expect("target set"), + RadrootsTransportSatisfactionPolicy::AnyTarget, + ) +} + +#[test] +fn default_profile_is_configured_preview_unavailable_and_rejecting() { + let profile = RadrootsReticulumPreviewProfile::default(); + let status = profile.status(); + + assert_eq!(profile.profile_id(), "transport.reticulum.preview"); + assert_eq!(profile.endpoint().as_str(), "reticulum:preview-unavailable"); + assert_eq!( + profile.behavior(), + RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts + ); + assert_eq!( + status.implementation_state, + RadrootsTransportImplementationState::PreviewUnavailable + ); + assert_eq!( + status.readiness, + RadrootsReticulumPreviewReadiness::PreviewUnavailable + ); +} + +#[test] +fn endpoint_and_profile_validation_are_strict_and_canonical() { + let endpoint = + RadrootsReticulumPreviewEndpoint::parse(" RETICULUM:Preview-Target ").expect("endpoint"); + assert_eq!(endpoint.as_str(), "reticulum:preview-target"); + assert_eq!(endpoint.to_string(), "reticulum:preview-target"); + assert_eq!(endpoint.clone().into_string(), "reticulum:preview-target"); + + assert_eq!( + RadrootsReticulumPreviewEndpoint::parse("reticulum:").expect_err("empty endpoint"), + RadrootsReticulumPreviewError::InvalidEndpoint + ); + assert_eq!( + RadrootsReticulumPreviewEndpoint::parse("https://target").expect_err("wrong scheme"), + RadrootsReticulumPreviewError::InvalidEndpoint + ); + assert_eq!( + RadrootsReticulumPreviewEndpoint::parse("reticulum:bad target") + .expect_err("whitespace endpoint"), + RadrootsReticulumPreviewError::InvalidEndpoint + ); + assert_eq!( + RadrootsReticulumPreviewProfile::new( + "transport reticulum", + endpoint, + RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts, + ) + .expect_err("profile id whitespace"), + RadrootsReticulumPreviewError::InvalidProfileId + ); +} + +#[test] +fn reject_delivery_attempts_returns_unavailable_without_success_or_nostr_routing() { + let transport = RadrootsReticulumPreviewTransport::default(); + let request = delivery_request(vec![ + reticulum_target("reticulum:preview-a"), + reticulum_target("reticulum:preview-b"), + ]); + let receipt = transport.deliver(request).expect("delivery receipt"); + + assert_eq!(receipt.target_receipts.len(), 2); + assert_eq!(receipt.satisfied_target_count(), 0); + for target_receipt in receipt.target_receipts { + assert_eq!(target_receipt.target.kind, RadrootsTransportKind::Reticulum); + assert_eq!( + target_receipt.status, + RadrootsTransportDeliveryTargetStatus::Unavailable + ); + assert_eq!( + target_receipt.outcome.code.as_deref(), + Some("transport_unavailable") + ); + } +} + +#[test] +fn deferred_delivery_plan_mode_never_counts_as_satisfied() { + let transport = RadrootsReticulumPreviewTransport::new( + RadrootsReticulumPreviewProfile::default() + .with_behavior(RadrootsReticulumPreviewBehavior::DeferDeliveryPlans), + ); + let request = delivery_request(vec![reticulum_target("reticulum:preview-deferred")]); + let receipt = transport.deliver(request).expect("delivery receipt"); + + assert_eq!(receipt.target_receipts.len(), 1); + assert_eq!(receipt.satisfied_target_count(), 0); + assert_eq!( + receipt.target_receipts[0].status, + RadrootsTransportDeliveryTargetStatus::Deferred + ); + assert_eq!( + receipt.target_receipts[0].outcome.code.as_deref(), + Some("deferred_until_implemented") + ); + assert!( + !RadrootsTransportSatisfactionPolicy::AnyTarget + .is_satisfied_by(1, receipt.satisfied_target_count()) + .expect("satisfaction check") + ); +} + +#[test] +fn non_reticulum_targets_are_rejected_without_nostr_routing() { + let transport = RadrootsReticulumPreviewTransport::default(); + let err = transport + .deliver(delivery_request(vec![nostr_target()])) + .expect_err("non-reticulum target"); + + assert_eq!(err, RadrootsReticulumPreviewError::NonReticulumTarget); +} + +#[test] +fn fetch_reports_preview_unavailable_without_observed_events() { + let transport = RadrootsReticulumPreviewTransport::default(); + let receipt = transport + .fetch(RadrootsReticulumPreviewFetchRequest::new("fetch-1", 10).expect("fetch request")) + .expect("fetch receipt"); + + assert_eq!(receipt.request_id, "fetch-1"); + assert_eq!(receipt.endpoint_uri, "reticulum:preview-unavailable"); + assert_eq!(receipt.observed_event_count, 0); + assert_eq!( + receipt.implementation_state, + RadrootsTransportImplementationState::PreviewUnavailable + ); + assert_eq!( + receipt.outcome.status, + RadrootsTransportDeliveryTargetStatus::Unavailable + ); + assert_eq!( + RadrootsReticulumPreviewFetchRequest::new("fetch-0", 0).expect_err("zero limit"), + RadrootsReticulumPreviewError::InvalidFetchLimit + ); +} + +#[test] +fn public_models_round_trip_through_serde() { + let profile = RadrootsReticulumPreviewProfile::default() + .with_behavior(RadrootsReticulumPreviewBehavior::DeferDeliveryPlans); + let json = serde_json::to_string(&profile).expect("profile json"); + let decoded: RadrootsReticulumPreviewProfile = + serde_json::from_str(&json).expect("profile decode"); + + assert_eq!(decoded, profile); +}