use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheView {
pub cap_bytes: u64,
pub used_bytes: u64,
pub dir: String,
pub shared: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncAvailability {
pub available: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SoftwareVersionDetail {
#[default]
Full,
Minor,
Off,
}
impl SoftwareVersionDetail {
pub fn render(self, product: &str, version: &semver::Version) -> String {
match self {
Self::Full => format!("{product}/{version}"),
Self::Minor => {
let coarsened = semver::Version::new(version.major, version.minor, 0);
if is_version_zero(&coarsened) {
return String::new();
}
format!("{product}/{coarsened}")
}
Self::Off => String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PeerSoftware {
Unknown,
Reported {
product: String,
version: semver::Version,
raw: String,
},
}
fn is_version_zero(version: &semver::Version) -> bool {
version.major == 0 && version.minor == 0 && version.patch == 0
}
const PRODUCT_VERSION_SEPARATOR: char = '/';
impl PeerSoftware {
pub fn parse(advertised: &str) -> Self {
let raw = advertised.trim();
let Some((product, version)) = raw.rsplit_once(PRODUCT_VERSION_SEPARATOR) else {
return Self::Unknown;
};
if product.is_empty() {
return Self::Unknown;
}
let Ok(version) = version.parse::<semver::Version>() else {
return Self::Unknown;
};
if is_version_zero(&version) {
return Self::Unknown;
}
Self::Reported {
product: product.to_string(),
version,
raw: raw.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusResult {
pub running: bool,
pub service: String,
pub version: String,
pub commit: String,
pub protocol: String,
pub uptime_secs: u64,
pub addr: String,
pub upstream: String,
pub cache: CacheView,
pub hosted_store_count: u64,
pub cached_capsule_count: u64,
pub pinned_store_count: u64,
pub sync: SyncAvailability,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigResult {
pub addr: String,
pub port: String,
pub upstream: String,
pub upstream_override: Option<String>,
pub cache_dir: String,
pub cache_shared: bool,
pub config_path: String,
pub sync_available: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetUpstreamResult {
pub upstream: String,
pub requires_restart: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetLevelResult {
pub filter: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetCapResult {
pub cap_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheClearResult {
pub cleared: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleEntry {
pub capsule: String,
pub root: String,
pub size_bytes: u64,
pub last_used_unix_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostedStore {
pub store_id: String,
pub pinned: bool,
pub capsule_count: u64,
pub total_bytes: u64,
pub capsules: Vec<CapsuleEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostedStoresListResult {
pub stores: Vec<HostedStore>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PinResult {
pub store_id: String,
pub root: Option<String>,
pub pinned: bool,
pub fetch: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnpinResult {
pub store_id: String,
pub unpinned: bool,
pub evicted_capsules: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostedStoreStatusResult {
pub store_id: String,
pub pinned: bool,
pub capsule_count: u64,
pub total_bytes: u64,
pub capsules: Vec<CapsuleEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncStatusResult {
pub available: bool,
pub method: String,
pub pinned_total: u64,
pub pinned_synced: u64,
pub whole_store_trigger_supported: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncTriggerResult {
pub store_id: String,
pub root: String,
pub status: String,
pub size_bytes: u64,
pub served_root: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingApproveResult {
pub approved: bool,
pub client_name: String,
pub token_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingRevokeResult {
pub revoked: bool,
pub token_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeersConnectResult {
pub connected: bool,
pub peer_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeersDisconnectResult {
pub disconnected: bool,
pub peer_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscribeResult {
pub subscribed: bool,
pub added: bool,
pub store_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnsubscribeResult {
pub subscribed: bool,
pub removed: bool,
pub store_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListSubscriptionsResult {
pub subscriptions: Vec<String>,
pub count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletBalanceResult {
pub balance: u64,
pub pending: u64,
pub source: Option<WalletReadSource>,
pub synced: bool,
pub peak_height: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WalletReadSource {
Db,
Fallback,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingRequestResult {
pub pairing_id: String,
pub pairing_code: String,
pub expires_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingPollResult {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub token: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn status_result_round_trips_the_node_shape() {
let v = json!({
"running": true, "service": "dig-node", "version": "0.30.0", "commit": "abc",
"protocol": "21", "uptime_secs": 5, "addr": "127.0.0.1:9256", "upstream": "https://rpc.dig.net",
"cache": {"cap_bytes": 1024, "used_bytes": 10, "dir": "/c", "shared": false},
"hosted_store_count": 2, "cached_capsule_count": 3, "pinned_store_count": 1,
"sync": {"available": true}
});
let parsed: StatusResult = serde_json::from_value(v.clone()).unwrap();
assert_eq!(serde_json::to_value(&parsed).unwrap(), v);
}
#[test]
fn config_result_keeps_upstream_override_null_when_unset() {
let parsed = ConfigResult {
addr: "127.0.0.1:9256".into(),
port: "9256".into(),
upstream: "https://rpc.dig.net".into(),
upstream_override: None,
cache_dir: "/c".into(),
cache_shared: false,
config_path: "/c/config.json".into(),
sync_available: true,
};
let v = serde_json::to_value(&parsed).unwrap();
assert_eq!(v["upstream_override"], json!(null));
assert!(v.as_object().unwrap().contains_key("upstream_override"));
}
#[test]
fn pairing_poll_omits_token_until_approved() {
let pending = PairingPollResult {
status: "pending".into(),
token: None,
};
let v = serde_json::to_value(&pending).unwrap();
assert_eq!(v, json!({"status": "pending"}));
let approved = PairingPollResult {
status: "approved".into(),
token: Some("deadbeef".into()),
};
assert_eq!(
serde_json::to_value(&approved).unwrap(),
json!({"status": "approved", "token": "deadbeef"})
);
}
#[test]
fn unknown_covers_empty_the_legacy_sentinel_and_garbage() {
for raw in [
"", "0.0.0", " ", "dig-node", "dig-node/", "dig-node/not-a-version", "/1.2.3", "1.2.3", "dig-node/0.0.0", ] {
assert_eq!(
PeerSoftware::parse(raw),
PeerSoftware::Unknown,
"{raw:?} must map to Unknown"
);
}
}
#[test]
fn reported_carries_product_version_and_the_raw_advertisement() {
let parsed = PeerSoftware::parse("dig-node/0.99.1");
let PeerSoftware::Reported {
product,
version,
raw,
} = parsed
else {
panic!("a well-formed advertisement must be Reported");
};
assert_eq!(product, "dig-node");
assert_eq!(version, semver::Version::new(0, 99, 1));
assert_eq!(raw, "dig-node/0.99.1");
}
#[test]
fn product_is_split_at_the_last_separator() {
let PeerSoftware::Reported {
product, version, ..
} = PeerSoftware::parse("acme/dig-node/1.2.3")
else {
panic!("expected Reported");
};
assert_eq!(product, "acme/dig-node");
assert_eq!(version, semver::Version::new(1, 2, 3));
}
#[test]
fn surrounding_whitespace_is_trimmed_before_parsing() {
let PeerSoftware::Reported {
product,
version,
raw,
} = PeerSoftware::parse(" dig-node/1.2.3 ")
else {
panic!("a padded advertisement must still be Reported");
};
assert_eq!(product, "dig-node");
assert_eq!(version, semver::Version::new(1, 2, 3));
assert_eq!(raw, "dig-node/1.2.3", "raw must record the trimmed value");
}
#[test]
fn prerelease_versions_are_preserved() {
let PeerSoftware::Reported { version, raw, .. } =
PeerSoftware::parse("dig-node/1.0.0-nightly.20260805")
else {
panic!("expected Reported");
};
assert_eq!(version.to_string(), "1.0.0-nightly.20260805");
assert_eq!(raw, "dig-node/1.0.0-nightly.20260805");
}
#[test]
fn unknown_serializes_as_a_tagged_object_with_no_version_field() {
let v = serde_json::to_value(PeerSoftware::Unknown).unwrap();
assert_eq!(v, json!({"kind": "unknown"}));
assert!(
v.get("version").is_none(),
"Unknown must not carry a version field at all"
);
}
#[test]
fn both_variants_round_trip_byte_identically() {
for wire in [
json!({"kind": "unknown"}),
json!({
"kind": "reported",
"product": "dig-node",
"version": "0.99.1",
"raw": "dig-node/0.99.1"
}),
] {
let parsed: PeerSoftware = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(serde_json::to_value(&parsed).unwrap(), wire);
}
}
#[test]
fn parse_then_serialize_matches_the_documented_json() {
assert_eq!(
serde_json::to_value(PeerSoftware::parse("dig-node/0.99.1")).unwrap(),
json!({
"kind": "reported",
"product": "dig-node",
"version": "0.99.1",
"raw": "dig-node/0.99.1"
})
);
assert_eq!(
serde_json::to_value(PeerSoftware::parse("0.0.0")).unwrap(),
json!({"kind": "unknown"})
);
}
struct Probe<T>(core::marker::PhantomData<T>);
trait ProbeFallback {
fn is_ord() -> bool {
false
}
}
impl<T> ProbeFallback for Probe<T> {}
impl<T: Ord> Probe<T> {
fn is_ord() -> bool {
true
}
}
struct PartialOrdProbe<T>(core::marker::PhantomData<T>);
trait PartialOrdFallback {
fn is_partial_ord() -> bool {
false
}
}
impl<T> PartialOrdFallback for PartialOrdProbe<T> {}
impl<T: PartialOrd> PartialOrdProbe<T> {
fn is_partial_ord() -> bool {
true
}
}
#[test]
fn peer_software_is_not_ordered() {
assert!(
Probe::<u32>::is_ord(),
"control: the probe must detect a type that IS Ord, or it proves nothing"
);
assert!(
!Probe::<PeerSoftware>::is_ord(),
"PeerSoftware must not implement Ord — comparison belongs after destructuring Reported"
);
}
#[test]
fn peer_software_has_no_default() {
struct DefaultProbe<T>(core::marker::PhantomData<T>);
trait DefaultFallback {
fn is_default() -> bool {
false
}
}
impl<T> DefaultFallback for DefaultProbe<T> {}
impl<T: Default> DefaultProbe<T> {
fn is_default() -> bool {
true
}
}
assert!(
DefaultProbe::<String>::is_default(),
"control: the probe must detect a type that IS Default, or it proves nothing"
);
assert!(
!DefaultProbe::<PeerSoftware>::is_default(),
"PeerSoftware must not implement Default"
);
}
#[test]
fn each_detail_mode_round_trips_to_the_intended_precision() {
let v = semver::Version::new(0, 99, 1);
let full = SoftwareVersionDetail::Full.render("dig-node", &v);
assert_eq!(full, "dig-node/0.99.1");
assert_eq!(
PeerSoftware::parse(&full),
PeerSoftware::parse("dig-node/0.99.1")
);
let minor = SoftwareVersionDetail::Minor.render("dig-node", &v);
assert_ne!(minor, full, "Minor must actually coarsen");
let PeerSoftware::Reported { version, .. } = PeerSoftware::parse(&minor) else {
panic!("a coarsened advertisement must still be READABLE, not Unknown");
};
assert_eq!(version.major, 0);
assert_eq!(version.minor, 99);
assert_eq!(version.patch, 0, "the patch level is what Minor hides");
let off = SoftwareVersionDetail::Off.render("dig-node", &v);
assert_eq!(off, "");
assert_eq!(PeerSoftware::parse(&off), PeerSoftware::Unknown);
}
#[test]
fn minor_mode_stays_valid_semver_rather_than_collapsing_to_unknown() {
let rendered =
SoftwareVersionDetail::Minor.render("dig-node", &semver::Version::new(1, 4, 7));
assert_eq!(rendered, "dig-node/1.4.0");
assert_ne!(
PeerSoftware::parse(&rendered),
PeerSoftware::Unknown,
"a coarsened build must remain readable; `product/1.4` would not be"
);
}
#[test]
fn minor_mode_strips_prerelease_and_build_metadata() {
let v: semver::Version = "1.0.0-nightly.20260805+sha.abc123".parse().unwrap();
let rendered = SoftwareVersionDetail::Minor.render("dig-node", &v);
assert_eq!(rendered, "dig-node/1.0.0");
assert!(
!rendered.contains("nightly"),
"the nightly identifier must not survive coarsening"
);
assert!(
!rendered.contains("abc123"),
"build metadata must not survive coarsening"
);
}
#[test]
fn off_mode_reveals_nothing_for_any_version() {
for v in ["0.0.1", "1.2.3", "99.99.99-rc.1"] {
let rendered = SoftwareVersionDetail::Off.render("dig-node", &v.parse().unwrap());
assert_eq!(
rendered, "",
"Off must reveal nothing, including the product name"
);
}
}
#[test]
fn detail_defaults_to_full() {
assert_eq!(
SoftwareVersionDetail::default(),
SoftwareVersionDetail::Full
);
}
#[test]
fn detail_uses_lowercase_wire_tokens() {
for (mode, token) in [
(SoftwareVersionDetail::Full, "\"full\""),
(SoftwareVersionDetail::Minor, "\"minor\""),
(SoftwareVersionDetail::Off, "\"off\""),
] {
assert_eq!(serde_json::to_string(&mode).unwrap(), token);
assert_eq!(
serde_json::from_str::<SoftwareVersionDetail>(token).unwrap(),
mode
);
}
}
#[test]
fn peer_software_is_not_partially_ordered_either() {
assert!(
PartialOrdProbe::<f64>::is_partial_ord(),
"control: the probe must detect a type that IS PartialOrd but NOT Ord, or it proves nothing about the gap between the two"
);
assert!(
PartialOrdProbe::<u32>::is_partial_ord(),
"control: a fully-ordered type must also be detected"
);
assert!(
!PartialOrdProbe::<PeerSoftware>::is_partial_ord(),
"PeerSoftware must implement neither PartialOrd nor Ord"
);
}
#[test]
fn version_zero_is_unknown_however_it_is_decorated() {
for raw in [
"dig-node/0.0.0",
"dig-node/0.0.0+build",
"dig-node/0.0.0-rc.1",
"x/0.0.0-0",
"dig-node/0.0.0-alpha+sha.abc123",
] {
assert_eq!(
PeerSoftware::parse(raw),
PeerSoftware::Unknown,
"{raw:?} is version zero and must be Unknown"
);
}
}
#[test]
fn a_nonzero_version_near_zero_is_still_reported() {
for raw in ["dig-node/0.0.1", "dig-node/0.1.0", "dig-node/0.0.1-rc.1"] {
assert_ne!(
PeerSoftware::parse(raw),
PeerSoftware::Unknown,
"{raw:?} is a real build, not the sentinel"
);
}
}
#[test]
fn every_rendering_is_empty_or_readable() {
let versions = [
"0.0.1",
"0.0.7",
"0.0.99", "0.1.0",
"0.99.1",
"1.0.0",
"1.4.7",
"10.20.30",
"1.0.0-nightly.20260805+sha.abc123",
"0.0.1-rc.1",
];
for mode in [
SoftwareVersionDetail::Full,
SoftwareVersionDetail::Minor,
SoftwareVersionDetail::Off,
] {
for v in versions {
let rendered = mode.render("dig-node", &v.parse().unwrap());
if rendered.is_empty() {
continue;
}
assert_ne!(
PeerSoftware::parse(&rendered),
PeerSoftware::Unknown,
"{mode:?} rendered {rendered:?} for {v}, which reads back as Unknown — a non-empty rendering must always be readable"
);
}
}
}
#[test]
fn minor_of_a_zero_zero_build_advertises_nothing_rather_than_the_sentinel() {
let rendered = SoftwareVersionDetail::Minor.render("dig-node", &"0.0.7".parse().unwrap());
assert_eq!(rendered, "");
assert_ne!(
rendered, "dig-node/0.0.0",
"the sentinel must never be ADVERTISED; it is only ever received from a legacy peer"
);
}
#[test]
fn raw_is_still_reconstructible_from_the_parsed_parts() {
for advertised in [
"dig-node/0.0.1",
"dig-node/0.99.1",
"dig-node/1.0.0-nightly.20260805",
"dig-node/1.0.0+sha.abc123",
"dig-node/1.0.0-rc.1+build.7",
"acme/dig-node/1.2.3",
] {
let PeerSoftware::Reported {
product,
version,
raw,
} = PeerSoftware::parse(advertised)
else {
panic!("{advertised:?} must be Reported");
};
assert_eq!(
raw,
format!("{product}/{version}"),
"raw diverged from the parsed parts for {advertised:?} — `raw` is now load-bearing; see this test's doc comment before changing anything"
);
}
}
}