Skip to main content

canic_subnet_catalog/model/
mod.rs

1use crate::{
2    CATALOG_SCHEMA_VERSION, CatalogError, parse_principal, principal_bytes,
3    resolver::routing_range_sorts_after,
4};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeSet;
7use std::str::FromStr;
8
9///
10/// SubnetCatalog
11///
12#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
13pub struct SubnetCatalog {
14    pub catalog_schema_version: u32,
15    pub network: String,
16    pub registry_canister_id: String,
17    pub registry_version: u64,
18    pub fetched_at: String,
19    pub fetched_by: String,
20    pub source_endpoint: String,
21    pub resolver_backend: String,
22    pub subnets: Vec<SubnetInfo>,
23    pub routing_ranges: Vec<RoutingRange>,
24}
25
26///
27/// SubnetInfo
28///
29#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
30pub struct SubnetInfo {
31    pub subnet_principal: String,
32    pub subnet_kind: SubnetKind,
33    pub subnet_kind_source: ClassificationSource,
34    pub subnet_specialization: SubnetSpecialization,
35    pub subnet_specialization_source: ClassificationSource,
36    pub geographic_scope: GeographicScope,
37    pub geographic_scope_source: ClassificationSource,
38    pub subnet_label: String,
39    pub subnet_label_source: ClassificationSource,
40    pub node_count: Option<u32>,
41    pub charges_apply_by_default: bool,
42}
43
44///
45/// RoutingRange
46///
47#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48pub struct RoutingRange {
49    pub start_canister_id: String,
50    pub end_canister_id: String,
51    pub subnet_principal: String,
52}
53
54///
55/// SubnetKind
56///
57#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
58#[serde(rename_all = "snake_case")]
59pub enum SubnetKind {
60    Application,
61    CloudEngine,
62    System,
63    Unknown,
64}
65
66impl SubnetKind {
67    #[must_use]
68    pub const fn as_str(self) -> &'static str {
69        match self {
70            Self::Application => "application",
71            Self::CloudEngine => "cloud_engine",
72            Self::System => "system",
73            Self::Unknown => "unknown",
74        }
75    }
76
77    #[must_use]
78    pub const fn charges_apply_by_default(self) -> bool {
79        matches!(self, Self::Application | Self::CloudEngine)
80    }
81}
82
83impl FromStr for SubnetKind {
84    type Err = String;
85
86    fn from_str(value: &str) -> Result<Self, Self::Err> {
87        match value {
88            "application" => Ok(Self::Application),
89            "cloud_engine" => Ok(Self::CloudEngine),
90            "system" => Ok(Self::System),
91            "unknown" => Ok(Self::Unknown),
92            other => Err(format!(
93                "invalid value {other}; use application, cloud_engine, system, or unknown"
94            )),
95        }
96    }
97}
98
99///
100/// SubnetSpecialization
101///
102#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
103#[serde(rename_all = "snake_case")]
104pub enum SubnetSpecialization {
105    None,
106    Fiduciary,
107    European,
108    Unknown,
109}
110
111impl SubnetSpecialization {
112    #[must_use]
113    pub const fn as_str(self) -> &'static str {
114        match self {
115            Self::None => "none",
116            Self::Fiduciary => "fiduciary",
117            Self::European => "european",
118            Self::Unknown => "unknown",
119        }
120    }
121}
122
123impl FromStr for SubnetSpecialization {
124    type Err = String;
125
126    fn from_str(value: &str) -> Result<Self, Self::Err> {
127        match value {
128            "none" => Ok(Self::None),
129            "fiduciary" => Ok(Self::Fiduciary),
130            "european" => Ok(Self::European),
131            "unknown" => Ok(Self::Unknown),
132            other => Err(format!(
133                "invalid value {other}; use none, fiduciary, european, or unknown"
134            )),
135        }
136    }
137}
138
139///
140/// GeographicScope
141///
142#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
143#[serde(rename_all = "snake_case")]
144pub enum GeographicScope {
145    Global,
146    Europe,
147    Unknown,
148}
149
150impl GeographicScope {
151    #[must_use]
152    pub const fn as_str(self) -> &'static str {
153        match self {
154            Self::Global => "global",
155            Self::Europe => "europe",
156            Self::Unknown => "unknown",
157        }
158    }
159}
160
161impl FromStr for GeographicScope {
162    type Err = String;
163
164    fn from_str(value: &str) -> Result<Self, Self::Err> {
165        match value {
166            "global" => Ok(Self::Global),
167            "europe" => Ok(Self::Europe),
168            "unknown" => Ok(Self::Unknown),
169            other => Err(format!(
170                "invalid value {other}; use global, europe, or unknown"
171            )),
172        }
173    }
174}
175
176///
177/// ClassificationSource
178///
179#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
180#[serde(rename_all = "snake_case")]
181pub enum ClassificationSource {
182    Registry,
183    Curated,
184    Computed,
185    Unknown,
186}
187
188impl ClassificationSource {
189    #[must_use]
190    pub const fn as_str(self) -> &'static str {
191        match self {
192            Self::Registry => "registry",
193            Self::Curated => "curated",
194            Self::Computed => "computed",
195            Self::Unknown => "unknown",
196        }
197    }
198}
199
200impl SubnetCatalog {
201    /// Validate schema, principal syntax, and routing references.
202    pub fn validate(&self) -> Result<(), CatalogError> {
203        if self.catalog_schema_version != CATALOG_SCHEMA_VERSION {
204            return Err(CatalogError::UnsupportedSchemaVersion {
205                found: self.catalog_schema_version,
206                supported: CATALOG_SCHEMA_VERSION,
207            });
208        }
209        if self.subnets.is_empty() {
210            return Err(CatalogError::EmptySubnets);
211        }
212        if self.routing_ranges.is_empty() {
213            return Err(CatalogError::EmptyRoutingRanges);
214        }
215        parse_principal(&self.registry_canister_id, "registry_canister_id")?;
216
217        let mut subnet_principals = BTreeSet::new();
218        for subnet in &self.subnets {
219            parse_principal(&subnet.subnet_principal, "subnet_principal")?;
220            if !subnet_principals.insert(subnet.subnet_principal.clone()) {
221                return Err(CatalogError::DuplicateSubnet {
222                    subnet_principal: subnet.subnet_principal.clone(),
223                });
224            }
225        }
226
227        for range in &self.routing_ranges {
228            if !subnet_principals.contains(&range.subnet_principal) {
229                return Err(CatalogError::UnknownRoutingSubnet {
230                    subnet_principal: range.subnet_principal.clone(),
231                });
232            }
233            let start = principal_bytes(&range.start_canister_id, "start_canister_id")?;
234            let end = principal_bytes(&range.end_canister_id, "end_canister_id")?;
235            parse_principal(&range.subnet_principal, "routing_range.subnet_principal")?;
236            if routing_range_sorts_after(&start, &end) {
237                return Err(CatalogError::InvalidRoutingRange {
238                    subnet_principal: range.subnet_principal.clone(),
239                    start_canister_id: range.start_canister_id.clone(),
240                    end_canister_id: range.end_canister_id.clone(),
241                });
242            }
243        }
244
245        Ok(())
246    }
247
248    #[must_use]
249    pub fn subnet_by_principal(&self, subnet_principal: &str) -> Option<&SubnetInfo> {
250        self.subnets
251            .iter()
252            .find(|subnet| subnet.subnet_principal == subnet_principal)
253    }
254
255    #[must_use]
256    pub fn routing_ranges_for_subnet(&self, subnet_principal: &str) -> Vec<&RoutingRange> {
257        self.routing_ranges
258            .iter()
259            .filter(|range| range.subnet_principal == subnet_principal)
260            .collect()
261    }
262}