lxmf-sdk 0.9.6

High-level Rust SDK for LXMF clients and RPC-backed LXMF workflows.
Documentation
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
#[non_exhaustive]
pub struct PropagationNodeSelectionState {
    #[serde(default)]
    pub peer: Option<String>,
    #[serde(default)]
    pub state: Option<String>,
    #[serde(default)]
    pub selected: bool,
    #[serde(default)]
    pub failure_kind: Option<String>,
    #[serde(default)]
    pub timed_out: bool,
    #[serde(default)]
    pub access_denied: bool,
    #[serde(default)]
    pub queue_depth: u64,
    #[serde(default)]
    pub retry_count: u64,
    #[serde(default)]
    pub next_sync_attempt: Option<i64>,
    #[serde(default)]
    pub last_sync_error: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[non_exhaustive]
pub struct PropagationNodeConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub peer_announce_at_start: bool,
    #[serde(default)]
    pub peer_announce_interval_secs: Option<u64>,
    #[serde(default)]
    pub node_announce_at_start: bool,
    #[serde(default)]
    pub node_announce_interval_secs: Option<u64>,
    #[serde(default = "default_propagation_node_transfer_limit_kb")]
    pub transfer_limit_kb: u32,
    #[serde(default = "default_propagation_node_sync_limit_kb")]
    pub sync_limit_kb: u32,
    #[serde(default = "default_propagation_node_stamp_cost")]
    pub stamp_cost: u32,
    #[serde(default = "default_propagation_node_stamp_cost_flexibility")]
    pub stamp_cost_flexibility: u32,
    #[serde(default = "default_propagation_node_peering_cost")]
    pub peering_cost: u32,
    #[serde(default)]
    pub control_allowed: Vec<String>,
}

impl Default for PropagationNodeConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            peer_announce_at_start: false,
            peer_announce_interval_secs: None,
            node_announce_at_start: false,
            node_announce_interval_secs: None,
            transfer_limit_kb: default_propagation_node_transfer_limit_kb(),
            sync_limit_kb: default_propagation_node_sync_limit_kb(),
            stamp_cost: default_propagation_node_stamp_cost(),
            stamp_cost_flexibility: default_propagation_node_stamp_cost_flexibility(),
            peering_cost: default_propagation_node_peering_cost(),
            control_allowed: Vec::new(),
        }
    }
}

impl PropagationNodeConfig {
    fn from_meta(meta: &JsonValue) -> Result<Self, String> {
        match meta.get("propagation_node") {
            None | Some(JsonValue::Null) => Ok(Self::default()),
            Some(value) => serde_json::from_value(value.clone())
                .map_err(|error| format!("invalid propagation node config: {error}")),
        }
    }
}

impl PropagationNodeSelectionState {
    fn from_peer_and_meta(peer: Option<String>, meta: &JsonValue) -> Result<Self, String> {
        macro_rules! field {
            ($getter:ident, $key:literal) => {
                $getter(meta, $key)
                    .map_err(|error| format!("propagation node field `{}` {error}", $key))?
            };
        }

        let state = field!(propagation_node_json_string, "state")
            .or(field!(propagation_node_json_string, "state_name"));
        let failure_kind = field!(propagation_node_json_string, "failure_kind");
        let timed_out = failure_kind.as_deref() == Some("timeout")
            || state.as_deref() == Some("timeout");
        let access_denied = field!(propagation_node_json_bool, "access_denied").unwrap_or(false)
            || matches!(
                failure_kind.as_deref(),
                Some("access_denied" | "access-denied" | "no_access")
            );
        let selected = peer.is_some()
            || field!(propagation_node_json_bool, "selected").unwrap_or(false);
        Ok(Self {
            peer,
            state,
            selected,
            failure_kind,
            timed_out,
            access_denied,
            queue_depth: field!(propagation_node_json_u64, "queue_depth").unwrap_or(0),
            retry_count: field!(propagation_node_json_u64, "retry_count").unwrap_or(0),
            next_sync_attempt: field!(propagation_node_json_i64, "next_sync_attempt"),
            last_sync_error: field!(propagation_node_json_string, "last_sync_error"),
        })
    }
}

#[derive(Clone, Debug, Serialize, PartialEq)]
#[non_exhaustive]
pub struct PropagationNodeSelectionResult {
    #[serde(default)]
    pub peer: Option<String>,
    #[serde(default)]
    pub meta: JsonValue,
    #[serde(default)]
    pub selection_state: PropagationNodeSelectionState,
    #[serde(default)]
    pub node_config: PropagationNodeConfig,
}

#[derive(Deserialize)]
struct RawPropagationNodeSelectionResult {
    #[serde(default)]
    peer: Option<String>,
    #[serde(default)]
    meta: JsonValue,
}

impl<'de> Deserialize<'de> for PropagationNodeSelectionResult {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let raw = RawPropagationNodeSelectionResult::deserialize(deserializer)?;
        let selection_state =
            PropagationNodeSelectionState::from_peer_and_meta(raw.peer.clone(), &raw.meta)
                .map_err(serde::de::Error::custom)?;
        let node_config =
            PropagationNodeConfig::from_meta(&raw.meta).map_err(serde::de::Error::custom)?;
        Ok(Self {
            peer: raw.peer,
            meta: raw.meta,
            selection_state,
            node_config,
        })
    }
}

fn default_propagation_node_transfer_limit_kb() -> u32 {
    256
}

fn default_propagation_node_sync_limit_kb() -> u32 {
    10240
}

fn default_propagation_node_stamp_cost() -> u32 {
    16
}

fn default_propagation_node_stamp_cost_flexibility() -> u32 {
    3
}

fn default_propagation_node_peering_cost() -> u32 {
    18
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
#[non_exhaustive]
pub struct PropagationNodeRecord {
    #[serde(default)]
    pub peer: Option<String>,
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub last_seen: Option<i64>,
    #[serde(default)]
    pub selected: bool,
    #[serde(default)]
    pub capabilities: Vec<String>,
}

impl PropagationNodeRecord {
    fn from_node(node: &JsonValue) -> Result<Self, String> {
        macro_rules! field {
            ($getter:ident, $key:literal) => {
                $getter(node, $key)
                    .map_err(|error| format!("propagation node field `{}` {error}", $key))?
            };
        }

        Ok(Self {
            peer: field!(propagation_node_json_string, "peer"),
            name: field!(propagation_node_json_string, "name"),
            last_seen: field!(propagation_node_json_i64, "last_seen"),
            selected: field!(propagation_node_json_bool, "selected").unwrap_or(false),
            capabilities: propagation_node_json_string_array(node, "capabilities")?,
        })
    }
}

#[derive(Clone, Debug, Serialize, PartialEq)]
#[non_exhaustive]
pub struct PropagationNodeListResult {
    #[serde(default)]
    pub nodes: Vec<JsonValue>,
    #[serde(default)]
    pub meta: JsonValue,
    #[serde(default)]
    pub node_records: Vec<PropagationNodeRecord>,
}

#[derive(Deserialize)]
struct RawPropagationNodeListResult {
    #[serde(default)]
    nodes: Vec<JsonValue>,
    #[serde(default)]
    meta: JsonValue,
}

impl<'de> Deserialize<'de> for PropagationNodeListResult {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let raw = RawPropagationNodeListResult::deserialize(deserializer)?;
        let node_records = raw
            .nodes
            .iter()
            .map(PropagationNodeRecord::from_node)
            .collect::<Result<Vec<_>, _>>()
            .map_err(serde::de::Error::custom)?;
        Ok(Self {
            nodes: raw.nodes,
            meta: raw.meta,
            node_records,
        })
    }
}

fn propagation_node_json_bool(value: &JsonValue, key: &str) -> Result<Option<bool>, &'static str> {
    match value.get(key) {
        None | Some(JsonValue::Null) => Ok(None),
        Some(v) => v.as_bool().ok_or("field is not a bool").map(Some),
    }
}

fn propagation_node_json_i64(value: &JsonValue, key: &str) -> Result<Option<i64>, &'static str> {
    match value.get(key) {
        None | Some(JsonValue::Null) => Ok(None),
        Some(v) => v.as_i64().ok_or("field is not an integer").map(Some),
    }
}

fn propagation_node_json_u64(value: &JsonValue, key: &str) -> Result<Option<u64>, &'static str> {
    match value.get(key) {
        None | Some(JsonValue::Null) => Ok(None),
        Some(v) => v.as_u64().ok_or("field is not an unsigned integer").map(Some),
    }
}

fn propagation_node_json_string(value: &JsonValue, key: &str) -> Result<Option<String>, &'static str> {
    match value.get(key) {
        None | Some(JsonValue::Null) => Ok(None),
        Some(v) => v.as_str().ok_or("field is not a string").map(|s| Some(s.to_owned())),
    }
}

fn propagation_node_json_string_array(
    value: &JsonValue,
    key: &str,
) -> Result<Vec<String>, String> {
    let Some(raw) = value.get(key) else {
        return Ok(Vec::new());
    };
    if raw.is_null() {
        return Ok(Vec::new());
    }
    let items = raw
        .as_array()
        .ok_or_else(|| format!("propagation node field `{key}` is not an array"))?;
    items
        .iter()
        .map(|item| {
            item.as_str()
                .map(ToOwned::to_owned)
                .ok_or_else(|| format!("propagation node field `{key}` contains a non-string"))
        })
        .collect()
}

#[cfg(test)]
mod propagation_node_tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn node_get_exposes_propagation_node_config_from_meta() {
        let result: PropagationNodeSelectionResult = serde_json::from_value(json!({
            "peer": null,
            "meta": {
                "propagation_node": {
                    "enabled": true,
                    "peer_announce_at_start": true,
                    "peer_announce_interval_secs": 120,
                    "node_announce_at_start": true,
                    "node_announce_interval_secs": 300,
                    "transfer_limit_kb": 512,
                    "sync_limit_kb": 20480,
                    "stamp_cost": 21,
                    "stamp_cost_flexibility": 4,
                    "peering_cost": 23,
                    "control_allowed": ["00112233445566778899aabbccddeeff"]
                }
            }
        }))
        .expect("decode node selection");

        assert!(result.node_config.enabled);
        assert!(result.node_config.peer_announce_at_start);
        assert_eq!(result.node_config.peer_announce_interval_secs, Some(120));
        assert!(result.node_config.node_announce_at_start);
        assert_eq!(result.node_config.node_announce_interval_secs, Some(300));
        assert_eq!(result.node_config.transfer_limit_kb, 512);
        assert_eq!(result.node_config.sync_limit_kb, 20480);
        assert_eq!(result.node_config.stamp_cost, 21);
        assert_eq!(result.node_config.stamp_cost_flexibility, 4);
        assert_eq!(result.node_config.peering_cost, 23);
        assert_eq!(
            result.node_config.control_allowed,
            vec!["00112233445566778899aabbccddeeff".to_string()]
        );
    }

    #[test]
    fn node_results_reject_malformed_typed_metadata() {
        let error = serde_json::from_value::<PropagationNodeSelectionResult>(json!({
            "peer": null,
            "meta": {"queue_depth": "many"}
        }))
        .expect_err("invalid queue depth");
        assert!(error.to_string().contains("queue_depth"));

        let error = serde_json::from_value::<PropagationNodeSelectionResult>(json!({
            "peer": null,
            "meta": {"propagation_node": {"enabled": "yes"}}
        }))
        .expect_err("invalid node config");
        assert!(error.to_string().contains("propagation node config"));

        let error = serde_json::from_value::<PropagationNodeListResult>(json!({
            "nodes": [{"capabilities": ["sync", 42]}]
        }))
        .expect_err("invalid capability entry");
        assert!(error.to_string().contains("capabilities"));
    }
}