zc2 0.0.27

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! The hub's `GET /api/provide/summary` response (spec §5.3), as the agent reads it.

use crate::agent::summary::PriceBounds;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubSummary {
    pub account: HubAccount,
    pub default_price_per_hour: Option<f64>,
    pub price_bounds: HubBounds,
    pub earnings: HubEarnings,
    pub devices: Vec<HubDevice>,
    pub web_base: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubAccount {
    pub username: String,
    pub email: String,
    pub credits_balance: f64,
    #[serde(default)]
    pub zakuro_user_id: Option<String>,
}

/// Floats on read so `1` and `1.0` both decode.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubBounds {
    pub min: f64,
    pub max: f64,
    pub step: f64,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubEarnings {
    pub today: f64,
    pub last_7d: f64,
    pub tz: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubDevice {
    pub node_pubkey: String,
    pub name: String,
    pub freshness: String,
    #[serde(default)]
    pub freshness_reason: Option<String>,
    #[serde(default)]
    pub last_seen_at: Option<String>,
    #[serde(default)]
    pub workers: Vec<HubWorker>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubWorker {
    pub id: i64,
    pub worker_id: String,
    pub status: String,
    #[serde(default)]
    pub last_seen: Option<String>,
    pub price_per_hour: Option<f64>,
    #[serde(default)]
    pub reported_price_per_hour: Option<f64>,
    pub effective_price_per_hour: Option<f64>,
    #[serde(default)]
    pub disabled: bool,
}

impl HubSummary {
    /// This Mac's worker rows: the device whose `node_pubkey` is our node key.
    pub fn this_mac_workers(&self, node_pubkey: &str) -> Vec<&HubWorker> {
        self.devices
            .iter()
            .filter(|d| d.node_pubkey == node_pubkey)
            .flat_map(|d| d.workers.iter())
            .collect()
    }

    pub fn bounds(&self) -> PriceBounds {
        let b = &self.price_bounds;
        PriceBounds {
            min: b.min.round().max(0.0) as u32,
            max: b.max.round().max(0.0) as u32,
            step: b.step.round().max(1.0) as u32,
        }
    }
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;

    /// Spec §5.3's example, verbatim apart from concrete values.
    pub(crate) const HUB_JSON: &str = r#"{
      "account": {"username": "jean", "email": "jean@zakuro-ai.com", "credits_balance": 1240.5, "zakuro_user_id": "1234567890"},
      "default_price_per_hour": 18.0,
      "price_bounds": {"min": 1, "max": 120, "step": 1},
      "earnings": {"today": 12.4, "last_7d": 88.1, "tz": "Asia/Tokyo"},
      "devices": [
        {"node_pubkey": "PK_THIS", "name": "jeans-mbp", "freshness": "fresh", "freshness_reason": "seen 20s ago",
         "last_seen_at": "2026-09-13T01:41:30Z",
         "workers": [
           {"id": 123, "worker_id": "a1b2c3d4-w0", "status": "online", "last_seen": "2026-09-13T01:41:30Z",
            "price_per_hour": null, "reported_price_per_hour": 3.6, "effective_price_per_hour": 18.0, "disabled": false}
         ]}
      ],
      "web_base": "https://stg.hub.zakuro-ai.com"
    }"#;

    #[test]
    fn parses_the_spec_example() {
        let h: HubSummary = serde_json::from_str(HUB_JSON).unwrap();
        assert_eq!(h.account.credits_balance, 1240.5);
        assert_eq!(h.devices[0].workers[0].id, 123);
        assert_eq!(h.devices[0].workers[0].price_per_hour, None);
        assert_eq!(h.this_mac_workers("PK_THIS").len(), 1);
        assert!(h.this_mac_workers("PK_OTHER").is_empty());
        assert_eq!(
            h.bounds(),
            crate::agent::summary::PriceBounds {
                min: 1,
                max: 120,
                step: 1
            }
        );
    }
}