Skip to main content

canic_host/nns_node/
mod.rs

1use crate::{
2    cache_file::{
3        CacheFileError, JsonCacheReport, LoadJsonCacheErrorHandlers, LoadJsonCacheRequest,
4        RefreshCacheWriteRequest, load_json_cache, write_json_refresh_cache,
5    },
6    nns_render::{compact_text, text_or_dash, yes_no},
7    subnet_catalog::format_utc_timestamp_secs,
8    table::{ColumnAlign, render_table},
9};
10use canic_ic_registry::{
11    DEFAULT_MAINNET_ENDPOINT, MainnetNodeList, MainnetRegistryFetchRequest, RegistryFetchError,
12    fetch_mainnet_node_list,
13};
14use canic_subnet_catalog::{MAINNET_NETWORK, canonical_principal_text};
15use serde::{Deserialize, Serialize};
16use std::{
17    io,
18    path::{Path, PathBuf},
19};
20use thiserror::Error as ThisError;
21
22pub const DEFAULT_NNS_NODE_SOURCE_ENDPOINT: &str = DEFAULT_MAINNET_ENDPOINT;
23pub const DEFAULT_NODE_REFRESH_LOCK_STALE_SECONDS: u64 = 30 * 60;
24pub const NNS_NODE_LIST_REPORT_SCHEMA_VERSION: u32 = 1;
25pub const NNS_NODE_INFO_REPORT_SCHEMA_VERSION: u32 = 1;
26pub const NNS_NODE_REFRESH_REPORT_SCHEMA_VERSION: u32 = 1;
27pub const NNS_NODE_SUBNET_KIND_APPLICATION: &str = "application";
28pub const NNS_NODE_SUBNET_KIND_SYSTEM: &str = "system";
29pub const NNS_NODE_SUBNET_KIND_UNKNOWN: &str = "unknown";
30const COMPACT_PRINCIPAL_CHARS: usize = 5;
31
32///
33/// NnsNodeCacheRequest
34///
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct NnsNodeCacheRequest {
37    pub icp_root: PathBuf,
38    pub network: String,
39}
40
41///
42/// NnsNodeListRequest
43///
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct NnsNodeListRequest {
46    pub cache: NnsNodeCacheRequest,
47    pub source_endpoint: String,
48    pub now_unix_secs: u64,
49    pub filters: NnsNodeListFilters,
50}
51
52///
53/// NnsNodeInfoRequest
54///
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct NnsNodeInfoRequest {
57    pub cache: NnsNodeCacheRequest,
58    pub source_endpoint: String,
59    pub input: String,
60    pub now_unix_secs: u64,
61}
62
63///
64/// NnsNodeRefreshRequest
65///
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct NnsNodeRefreshRequest {
68    pub cache: NnsNodeCacheRequest,
69    pub source_endpoint: String,
70    pub now_unix_secs: u64,
71    pub lock_stale_after_seconds: u64,
72    pub dry_run: bool,
73    pub output_path: Option<PathBuf>,
74}
75
76///
77/// NnsNodeListFilters
78///
79#[derive(Clone, Debug, Default, Eq, PartialEq)]
80pub struct NnsNodeListFilters {
81    pub subnet: Option<String>,
82    pub subnet_kind: Option<String>,
83    pub data_center: Option<String>,
84    pub node_provider: Option<String>,
85    pub node_operator: Option<String>,
86}
87
88impl NnsNodeListFilters {
89    #[must_use]
90    pub const fn is_empty(&self) -> bool {
91        self.subnet.is_none()
92            && self.subnet_kind.is_none()
93            && self.data_center.is_none()
94            && self.node_provider.is_none()
95            && self.node_operator.is_none()
96    }
97}
98
99///
100/// CachedNnsNodeReport
101///
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct CachedNnsNodeReport {
104    pub path: PathBuf,
105    pub report: NnsNodeListReport,
106}
107
108///
109/// NnsNodeListReport
110///
111#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
112pub struct NnsNodeListReport {
113    pub schema_version: u32,
114    pub network: String,
115    pub registry_canister_id: String,
116    pub registry_version: u64,
117    pub fetched_at: String,
118    pub source_endpoint: String,
119    pub fetched_by: String,
120    pub node_count: usize,
121    pub nodes: Vec<NnsNodeRow>,
122}
123
124impl JsonCacheReport for NnsNodeListReport {
125    fn schema_version(&self) -> u32 {
126        self.schema_version
127    }
128
129    fn network(&self) -> &str {
130        &self.network
131    }
132}
133
134///
135/// NnsNodeRow
136///
137#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
138pub struct NnsNodeRow {
139    pub node_principal: String,
140    pub node_operator_principal: String,
141    pub node_provider_principal: String,
142    pub subnet_principal: String,
143    pub subnet_kind: String,
144    pub data_center_id: String,
145}
146
147///
148/// NnsNodeInfoReport
149///
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
151pub struct NnsNodeInfoReport {
152    pub schema_version: u32,
153    pub input: String,
154    pub resolved_from: String,
155    pub network: String,
156    pub registry_canister_id: String,
157    pub registry_version: u64,
158    pub fetched_at: String,
159    pub source_endpoint: String,
160    pub fetched_by: String,
161    pub node_principal: String,
162    pub node_operator_principal: String,
163    pub node_provider_principal: String,
164    pub subnet_principal: String,
165    pub subnet_kind: String,
166    pub data_center_id: String,
167}
168
169///
170/// NnsNodeRefreshReport
171///
172#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
173pub struct NnsNodeRefreshReport {
174    pub schema_version: u32,
175    pub network: String,
176    pub cache_path: String,
177    pub refresh_lock_path: String,
178    pub output_path: Option<String>,
179    pub registry_canister_id: String,
180    pub registry_version: u64,
181    pub fetched_at: String,
182    pub source_endpoint: String,
183    pub fetched_by: String,
184    pub dry_run: bool,
185    pub wrote_cache: bool,
186    pub replaced_existing_cache: bool,
187    pub node_count: usize,
188}
189
190///
191/// NnsNodeHostError
192///
193#[derive(Debug, ThisError)]
194pub enum NnsNodeHostError {
195    #[error(
196        "`canic nns node` supports only the mainnet `ic` network\n\nThe NNS node list is derived from public Internet Computer mainnet registry records.\nLocal replica NNS registry discovery is not implemented yet.\n\nTry:\n  canic --network ic nns node list"
197    )]
198    UnsupportedNetwork { network: String },
199
200    #[error("node cache is missing at {}", path.display())]
201    MissingCache { path: PathBuf },
202
203    #[error("failed to read node cache at {}: {source}", path.display())]
204    ReadCache { path: PathBuf, source: io::Error },
205
206    #[error("failed to parse node cache at {}: {source}", path.display())]
207    ParseCache {
208        path: PathBuf,
209        source: serde_json::Error,
210    },
211
212    #[error("failed to serialize node cache JSON for {}: {source}", path.display())]
213    SerializeCache {
214        path: PathBuf,
215        source: serde_json::Error,
216    },
217
218    #[error("unsupported node cache schema version {version}; expected {expected}")]
219    UnsupportedCacheSchemaVersion { version: u32, expected: u32 },
220
221    #[error("cached node network mismatch: path is for {requested}, report is for {actual}")]
222    NetworkMismatch { requested: String, actual: String },
223
224    #[error("node refresh is already in progress; lock exists at {} since unix_ms={started_at_unix_ms}", path.display())]
225    RefreshAlreadyInProgress {
226        path: PathBuf,
227        started_at_unix_ms: u64,
228    },
229
230    #[error("failed to create node cache directory at {}: {source}", path.display())]
231    CreateCacheDirectory { path: PathBuf, source: io::Error },
232
233    #[error("failed to create node refresh lock at {}: {source}", path.display())]
234    CreateRefreshLock { path: PathBuf, source: io::Error },
235
236    #[error("failed to read node refresh lock at {}: {source}", path.display())]
237    ReadRefreshLock { path: PathBuf, source: io::Error },
238
239    #[error("failed to parse node refresh lock at {}: {source}", path.display())]
240    ParseRefreshLock {
241        path: PathBuf,
242        source: serde_json::Error,
243    },
244
245    #[error("failed to write node refresh lock at {}: {source}", path.display())]
246    WriteRefreshLock { path: PathBuf, source: io::Error },
247
248    #[error("failed to remove node refresh lock at {}: {source}", path.display())]
249    RemoveRefreshLock { path: PathBuf, source: io::Error },
250
251    #[error("live NNS node refresh failed: {0}")]
252    NnsQuery(#[from] RegistryFetchError),
253
254    #[error("failed to write node cache temp file at {}: {source}", path.display())]
255    WriteCacheTemp { path: PathBuf, source: io::Error },
256
257    #[error("failed to sync node cache temp file at {}: {source}", path.display())]
258    SyncCacheTemp { path: PathBuf, source: io::Error },
259
260    #[error("failed to replace node cache at {} from {}: {source}", cache_path.display(), temp_path.display())]
261    ReplaceCache {
262        temp_path: PathBuf,
263        cache_path: PathBuf,
264        source: io::Error,
265    },
266
267    #[error("failed to sync node cache directory at {}: {source}", path.display())]
268    SyncCacheDirectory { path: PathBuf, source: io::Error },
269
270    #[error("failed to write refreshed node output at {}: {source}", path.display())]
271    WriteRefreshOutput { path: PathBuf, source: io::Error },
272
273    #[error("failed to sync refreshed node output at {}: {source}", path.display())]
274    SyncRefreshOutput { path: PathBuf, source: io::Error },
275
276    #[error("node {input:?} did not match the mainnet NNS node list")]
277    NodeNotFound { input: String },
278
279    #[error("node prefix {prefix:?} is ambiguous; matches: {matches:?}")]
280    AmbiguousNodePrefix {
281        prefix: String,
282        matches: Vec<String>,
283    },
284}
285
286#[must_use]
287pub fn nns_node_cache_path(icp_root: &Path, network: &str) -> PathBuf {
288    icp_root
289        .join(".canic")
290        .join("node")
291        .join(network)
292        .join("nodes.json")
293}
294
295#[must_use]
296pub fn nns_node_refresh_lock_path(icp_root: &Path, network: &str) -> PathBuf {
297    icp_root
298        .join(".canic")
299        .join("node")
300        .join(network)
301        .join("refresh.lock")
302}
303
304pub fn build_nns_node_list_report(
305    request: &NnsNodeListRequest,
306) -> Result<NnsNodeListReport, NnsNodeHostError> {
307    build_nns_node_list_report_with_source(request, &LiveNnsNodeSource)
308}
309
310pub fn build_nns_node_info_report(
311    request: &NnsNodeInfoRequest,
312) -> Result<NnsNodeInfoReport, NnsNodeHostError> {
313    build_nns_node_info_report_with_source(request, &LiveNnsNodeSource)
314}
315
316pub fn refresh_nns_node_report(
317    request: &NnsNodeRefreshRequest,
318) -> Result<NnsNodeRefreshReport, NnsNodeHostError> {
319    refresh_nns_node_report_with_source(request, &LiveNnsNodeSource)
320}
321
322fn load_cached_nns_node_report(
323    request: &NnsNodeCacheRequest,
324) -> Result<CachedNnsNodeReport, NnsNodeHostError> {
325    enforce_mainnet_network(&request.network)?;
326    let path = nns_node_cache_path(&request.icp_root, &request.network);
327    let cached = load_json_cache(
328        LoadJsonCacheRequest {
329            path,
330            network: &request.network,
331            expected_schema_version: NNS_NODE_LIST_REPORT_SCHEMA_VERSION,
332        },
333        LoadJsonCacheErrorHandlers {
334            missing_cache: |path| NnsNodeHostError::MissingCache { path },
335            read_cache: |path, source| NnsNodeHostError::ReadCache { path, source },
336            parse_cache: |path, source| NnsNodeHostError::ParseCache { path, source },
337            unsupported_schema: |version, expected| {
338                NnsNodeHostError::UnsupportedCacheSchemaVersion { version, expected }
339            },
340            network_mismatch: |requested, actual| NnsNodeHostError::NetworkMismatch {
341                requested,
342                actual,
343            },
344        },
345    )?;
346    Ok(CachedNnsNodeReport {
347        path: cached.path,
348        report: cached.report,
349    })
350}
351
352fn build_nns_node_list_report_with_source(
353    request: &NnsNodeListRequest,
354    source: &dyn NnsNodeSource,
355) -> Result<NnsNodeListReport, NnsNodeHostError> {
356    let report = match load_cached_nns_node_report(&request.cache) {
357        Ok(cached) => cached.report,
358        Err(NnsNodeHostError::MissingCache { .. }) => {
359            let refresh_request = NnsNodeRefreshRequest {
360                cache: request.cache.clone(),
361                source_endpoint: request.source_endpoint.clone(),
362                now_unix_secs: request.now_unix_secs,
363                lock_stale_after_seconds: DEFAULT_NODE_REFRESH_LOCK_STALE_SECONDS,
364                dry_run: false,
365                output_path: None,
366            };
367            let (report, _) = refresh_nns_node_cache_with_source(&refresh_request, source)?;
368            report
369        }
370        Err(err) => return Err(err),
371    };
372    Ok(filter_node_list_report(report, &request.filters))
373}
374
375fn build_nns_node_info_report_with_source(
376    request: &NnsNodeInfoRequest,
377    source: &dyn NnsNodeSource,
378) -> Result<NnsNodeInfoReport, NnsNodeHostError> {
379    let list_request = NnsNodeListRequest {
380        cache: request.cache.clone(),
381        source_endpoint: request.source_endpoint.clone(),
382        now_unix_secs: request.now_unix_secs,
383        filters: NnsNodeListFilters::default(),
384    };
385    let report = build_nns_node_list_report_with_source(&list_request, source)?;
386    let (node, resolved_from) = resolve_node(&report, &request.input)?;
387    Ok(NnsNodeInfoReport {
388        schema_version: NNS_NODE_INFO_REPORT_SCHEMA_VERSION,
389        input: request.input.clone(),
390        resolved_from,
391        network: report.network,
392        registry_canister_id: report.registry_canister_id,
393        registry_version: report.registry_version,
394        fetched_at: report.fetched_at,
395        source_endpoint: report.source_endpoint,
396        fetched_by: report.fetched_by,
397        node_principal: node.node_principal,
398        node_operator_principal: node.node_operator_principal,
399        node_provider_principal: node.node_provider_principal,
400        subnet_principal: node.subnet_principal,
401        subnet_kind: node.subnet_kind,
402        data_center_id: node.data_center_id,
403    })
404}
405
406fn refresh_nns_node_report_with_source(
407    request: &NnsNodeRefreshRequest,
408    source: &dyn NnsNodeSource,
409) -> Result<NnsNodeRefreshReport, NnsNodeHostError> {
410    refresh_nns_node_cache_with_source(request, source).map(|(_, report)| report)
411}
412
413fn refresh_nns_node_cache_with_source(
414    request: &NnsNodeRefreshRequest,
415    source: &dyn NnsNodeSource,
416) -> Result<(NnsNodeListReport, NnsNodeRefreshReport), NnsNodeHostError> {
417    enforce_mainnet_network(&request.cache.network)?;
418    let cache_path = nns_node_cache_path(&request.cache.icp_root, &request.cache.network);
419    let lock_path = nns_node_refresh_lock_path(&request.cache.icp_root, &request.cache.network);
420    let report = fetch_nns_node_list_report_with_source(
421        &request.cache.network,
422        &request.source_endpoint,
423        request.now_unix_secs,
424        source,
425    )?;
426    let write_result = write_json_refresh_cache(
427        RefreshCacheWriteRequest {
428            cache_path: &cache_path,
429            lock_path: &lock_path,
430            network: &request.cache.network,
431            now_unix_secs: request.now_unix_secs,
432            lock_stale_after_seconds: request.lock_stale_after_seconds,
433            dry_run: request.dry_run,
434            output_path: request.output_path.as_deref(),
435            report: &report,
436        },
437        node_cache_error,
438        |path, source| NnsNodeHostError::SerializeCache { path, source },
439    )?;
440    let refresh_report = NnsNodeRefreshReport {
441        schema_version: NNS_NODE_REFRESH_REPORT_SCHEMA_VERSION,
442        network: report.network.clone(),
443        cache_path: write_result.cache_path,
444        refresh_lock_path: write_result.refresh_lock_path,
445        output_path: write_result.output_path,
446        registry_canister_id: report.registry_canister_id.clone(),
447        registry_version: report.registry_version,
448        fetched_at: report.fetched_at.clone(),
449        source_endpoint: report.source_endpoint.clone(),
450        fetched_by: report.fetched_by.clone(),
451        dry_run: request.dry_run,
452        wrote_cache: write_result.wrote_cache,
453        replaced_existing_cache: write_result.replaced_existing_cache,
454        node_count: report.node_count,
455    };
456    Ok((report, refresh_report))
457}
458
459fn fetch_nns_node_list_report_with_source(
460    network: &str,
461    source_endpoint: &str,
462    now_unix_secs: u64,
463    source: &dyn NnsNodeSource,
464) -> Result<NnsNodeListReport, NnsNodeHostError> {
465    enforce_mainnet_network(network)?;
466    let fetched_at = format_utc_timestamp_secs(now_unix_secs);
467    let mut fetch_request = MainnetRegistryFetchRequest::new(fetched_at);
468    fetch_request.endpoint = source_endpoint.to_string();
469    let list = source.fetch_nodes(&fetch_request)?;
470    Ok(node_report_from_list(list))
471}
472
473fn node_cache_error(err: CacheFileError) -> NnsNodeHostError {
474    match err {
475        CacheFileError::CreateDirectory { path, source } => {
476            NnsNodeHostError::CreateCacheDirectory { path, source }
477        }
478        CacheFileError::CreateRefreshLock { path, source } => {
479            NnsNodeHostError::CreateRefreshLock { path, source }
480        }
481        CacheFileError::ReadRefreshLock { path, source } => {
482            NnsNodeHostError::ReadRefreshLock { path, source }
483        }
484        CacheFileError::ParseRefreshLock { path, source } => {
485            NnsNodeHostError::ParseRefreshLock { path, source }
486        }
487        CacheFileError::WriteRefreshLock { path, source } => {
488            NnsNodeHostError::WriteRefreshLock { path, source }
489        }
490        CacheFileError::RemoveRefreshLock { path, source } => {
491            NnsNodeHostError::RemoveRefreshLock { path, source }
492        }
493        CacheFileError::RefreshAlreadyInProgress {
494            path,
495            started_at_unix_ms,
496        } => NnsNodeHostError::RefreshAlreadyInProgress {
497            path,
498            started_at_unix_ms,
499        },
500        CacheFileError::WriteTemp { path, source } => {
501            NnsNodeHostError::WriteCacheTemp { path, source }
502        }
503        CacheFileError::SyncTemp { path, source } => {
504            NnsNodeHostError::SyncCacheTemp { path, source }
505        }
506        CacheFileError::Replace {
507            temp_path,
508            target_path,
509            source,
510        } => NnsNodeHostError::ReplaceCache {
511            temp_path,
512            cache_path: target_path,
513            source,
514        },
515        CacheFileError::SyncDirectory { path, source } => {
516            NnsNodeHostError::SyncCacheDirectory { path, source }
517        }
518        CacheFileError::WriteOutput { path, source } => {
519            NnsNodeHostError::WriteRefreshOutput { path, source }
520        }
521        CacheFileError::SyncOutput { path, source } => {
522            NnsNodeHostError::SyncRefreshOutput { path, source }
523        }
524    }
525}
526
527#[must_use]
528pub fn nns_node_list_report_text(report: &NnsNodeListReport) -> String {
529    let mut lines = Vec::new();
530    lines.push(format!(
531        "nodes: {} count {} fetched_at {}",
532        report.network, report.node_count, report.fetched_at
533    ));
534    if report.nodes.is_empty() {
535        lines.push("nodes: none".to_string());
536        return lines.join("\n");
537    }
538    let headers = ["NODE", "OPERATOR", "PROVIDER", "SUBNET", "KIND", "DC"];
539    let rows = report
540        .nodes
541        .iter()
542        .map(|node| {
543            [
544                compact_text(&node.node_principal, COMPACT_PRINCIPAL_CHARS),
545                compact_text(&node.node_operator_principal, COMPACT_PRINCIPAL_CHARS),
546                compact_text(&node.node_provider_principal, COMPACT_PRINCIPAL_CHARS),
547                compact_text(&node.subnet_principal, COMPACT_PRINCIPAL_CHARS),
548                node.subnet_kind.clone(),
549                text_or_dash(Some(&node.data_center_id)).to_string(),
550            ]
551        })
552        .collect::<Vec<_>>();
553    let alignments = [
554        ColumnAlign::Left,
555        ColumnAlign::Left,
556        ColumnAlign::Left,
557        ColumnAlign::Left,
558        ColumnAlign::Left,
559        ColumnAlign::Left,
560    ];
561    lines.push(render_table(&headers, &rows, &alignments));
562    lines.join("\n")
563}
564
565#[must_use]
566pub fn nns_node_list_report_verbose_text(report: &NnsNodeListReport) -> String {
567    let mut lines = Vec::new();
568    lines.push(format!("source_endpoint: {}", report.source_endpoint));
569    lines.push(format!("fetched_by: {}", report.fetched_by));
570    if report.nodes.is_empty() {
571        lines.push("nodes: none".to_string());
572        return lines.join("\n");
573    }
574    let headers = [
575        "NODE",
576        "OPERATOR",
577        "PROVIDER",
578        "SUBNET",
579        "KIND",
580        "DC",
581        "REGISTRY_VERSION",
582        "FETCHED_AT",
583    ];
584    let rows = report
585        .nodes
586        .iter()
587        .map(|node| {
588            [
589                node.node_principal.clone(),
590                node.node_operator_principal.clone(),
591                node.node_provider_principal.clone(),
592                node.subnet_principal.clone(),
593                node.subnet_kind.clone(),
594                text_or_dash(Some(&node.data_center_id)).to_string(),
595                report.registry_version.to_string(),
596                report.fetched_at.clone(),
597            ]
598        })
599        .collect::<Vec<_>>();
600    let alignments = [
601        ColumnAlign::Left,
602        ColumnAlign::Left,
603        ColumnAlign::Left,
604        ColumnAlign::Left,
605        ColumnAlign::Left,
606        ColumnAlign::Left,
607        ColumnAlign::Right,
608        ColumnAlign::Left,
609    ];
610    lines.push(render_table(&headers, &rows, &alignments));
611    lines.join("\n")
612}
613
614#[must_use]
615pub fn nns_node_info_report_text(report: &NnsNodeInfoReport) -> String {
616    [
617        format!("input: {}", report.input),
618        format!("resolved_from: {}", report.resolved_from),
619        format!("node_principal: {}", report.node_principal),
620        format!(
621            "node_operator_principal: {}",
622            report.node_operator_principal
623        ),
624        format!(
625            "node_provider_principal: {}",
626            report.node_provider_principal
627        ),
628        format!("subnet_principal: {}", report.subnet_principal),
629        format!("subnet_kind: {}", report.subnet_kind),
630        format!(
631            "data_center_id: {}",
632            text_or_dash(Some(&report.data_center_id))
633        ),
634        format!("registry_canister_id: {}", report.registry_canister_id),
635        format!("registry_version: {}", report.registry_version),
636        format!("network: {}", report.network),
637        format!("fetched_at: {}", report.fetched_at),
638        format!("source_endpoint: {}", report.source_endpoint),
639        format!("fetched_by: {}", report.fetched_by),
640    ]
641    .join("\n")
642}
643
644#[must_use]
645pub fn nns_node_refresh_report_text(report: &NnsNodeRefreshReport) -> String {
646    [
647        format!("network: {}", report.network),
648        format!("cache_path: {}", report.cache_path),
649        format!("refresh_lock_path: {}", report.refresh_lock_path),
650        format!("registry_canister_id: {}", report.registry_canister_id),
651        format!("registry_version: {}", report.registry_version),
652        format!("fetched_at: {}", report.fetched_at),
653        format!("source_endpoint: {}", report.source_endpoint),
654        format!("fetched_by: {}", report.fetched_by),
655        format!("dry_run: {}", yes_no(report.dry_run)),
656        format!("wrote_cache: {}", yes_no(report.wrote_cache)),
657        format!(
658            "replaced_existing_cache: {}",
659            yes_no(report.replaced_existing_cache)
660        ),
661        format!("node_count: {}", report.node_count),
662    ]
663    .join("\n")
664}
665
666fn node_report_from_list(list: MainnetNodeList) -> NnsNodeListReport {
667    let nodes = list
668        .nodes
669        .into_iter()
670        .map(|node| NnsNodeRow {
671            node_principal: node.principal,
672            node_operator_principal: node.node_operator_principal,
673            node_provider_principal: node.node_provider_principal,
674            subnet_principal: node.subnet_principal,
675            subnet_kind: node.subnet_kind,
676            data_center_id: node.data_center_id,
677        })
678        .collect::<Vec<_>>();
679    NnsNodeListReport {
680        schema_version: NNS_NODE_LIST_REPORT_SCHEMA_VERSION,
681        network: list.network,
682        registry_canister_id: list.registry_canister_id,
683        registry_version: list.registry_version,
684        fetched_at: list.fetched_at,
685        source_endpoint: list.source_endpoint,
686        fetched_by: list.fetched_by,
687        node_count: nodes.len(),
688        nodes,
689    }
690}
691
692fn filter_node_list_report(
693    mut report: NnsNodeListReport,
694    filters: &NnsNodeListFilters,
695) -> NnsNodeListReport {
696    if filters.is_empty() {
697        return report;
698    }
699    report
700        .nodes
701        .retain(|node| node_matches_filters(node, filters));
702    report.node_count = report.nodes.len();
703    report
704}
705
706fn node_matches_filters(node: &NnsNodeRow, filters: &NnsNodeListFilters) -> bool {
707    filters
708        .subnet
709        .as_deref()
710        .is_none_or(|filter| principal_filter_matches(&node.subnet_principal, filter))
711        && filters
712            .subnet_kind
713            .as_deref()
714            .is_none_or(|filter| text_filter_equals(&node.subnet_kind, filter))
715        && filters
716            .data_center
717            .as_deref()
718            .is_none_or(|filter| text_filter_starts_with(&node.data_center_id, filter))
719        && filters
720            .node_provider
721            .as_deref()
722            .is_none_or(|filter| principal_filter_matches(&node.node_provider_principal, filter))
723        && filters
724            .node_operator
725            .as_deref()
726            .is_none_or(|filter| principal_filter_matches(&node.node_operator_principal, filter))
727}
728
729fn principal_filter_matches(value: &str, filter: &str) -> bool {
730    let Some(filter) = non_empty_filter(filter) else {
731        return false;
732    };
733    if let Ok(principal) = canonical_principal_text(filter) {
734        value == principal
735    } else {
736        value.starts_with(&filter.to_ascii_lowercase())
737    }
738}
739
740fn text_filter_starts_with(value: &str, filter: &str) -> bool {
741    let Some(filter) = non_empty_filter(filter) else {
742        return false;
743    };
744    value
745        .to_ascii_lowercase()
746        .starts_with(&filter.to_ascii_lowercase())
747}
748
749fn text_filter_equals(value: &str, filter: &str) -> bool {
750    let Some(filter) = non_empty_filter(filter) else {
751        return false;
752    };
753    value.eq_ignore_ascii_case(filter)
754}
755
756fn non_empty_filter(filter: &str) -> Option<&str> {
757    let filter = filter.trim();
758    (!filter.is_empty()).then_some(filter)
759}
760
761///
762/// NnsNodeSource
763///
764trait NnsNodeSource {
765    fn fetch_nodes(
766        &self,
767        request: &MainnetRegistryFetchRequest,
768    ) -> Result<MainnetNodeList, NnsNodeHostError>;
769}
770
771///
772/// LiveNnsNodeSource
773///
774struct LiveNnsNodeSource;
775
776impl NnsNodeSource for LiveNnsNodeSource {
777    fn fetch_nodes(
778        &self,
779        request: &MainnetRegistryFetchRequest,
780    ) -> Result<MainnetNodeList, NnsNodeHostError> {
781        Ok(fetch_mainnet_node_list(request)?)
782    }
783}
784
785fn enforce_mainnet_network(network: &str) -> Result<(), NnsNodeHostError> {
786    if network == MAINNET_NETWORK {
787        return Ok(());
788    }
789    Err(NnsNodeHostError::UnsupportedNetwork {
790        network: network.to_string(),
791    })
792}
793
794fn resolve_node(
795    report: &NnsNodeListReport,
796    input: &str,
797) -> Result<(NnsNodeRow, String), NnsNodeHostError> {
798    if let Ok(principal) = canonical_principal_text(input)
799        && let Some(node) = report
800            .nodes
801            .iter()
802            .find(|node| node.node_principal == principal)
803    {
804        return Ok((node.clone(), "node_principal".to_string()));
805    }
806
807    let prefix = input.trim().to_ascii_lowercase();
808    if prefix.is_empty() {
809        return Err(NnsNodeHostError::NodeNotFound {
810            input: input.to_string(),
811        });
812    }
813    let matches = report
814        .nodes
815        .iter()
816        .filter(|node| node.node_principal.starts_with(&prefix))
817        .cloned()
818        .collect::<Vec<_>>();
819    match matches.as_slice() {
820        [node] => Ok((node.clone(), "node_principal_prefix".to_string())),
821        [] => Err(NnsNodeHostError::NodeNotFound {
822            input: input.to_string(),
823        }),
824        _ => Err(NnsNodeHostError::AmbiguousNodePrefix {
825            prefix,
826            matches: matches
827                .into_iter()
828                .map(|node| node.node_principal)
829                .collect(),
830        }),
831    }
832}
833
834#[cfg(test)]
835mod tests;