Skip to main content

canic_host/subnet_catalog/
mod.rs

1use crate::{
2    cache_file::{
3        CacheFileError, RefreshLockRequest, acquire_refresh_lock, create_directory,
4        write_text_atomically, write_text_output,
5    },
6    duration::parse_duration_seconds,
7    table::{ColumnAlign, render_table},
8};
9use canic_ic_registry::{
10    DEFAULT_MAINNET_ENDPOINT, MainnetRegistryFetchRequest, RegistryFetchError,
11    fetch_mainnet_subnet_catalog,
12};
13use canic_subnet_catalog::{
14    CatalogError, ClassificationSource, GeographicScope, MAINNET_NETWORK, ResolveAs,
15    ResolvedSubnetSubject, RoutingRange, SubnetCatalog, SubnetInfo, SubnetKind,
16    SubnetSpecialization, catalog_to_pretty_json, parse_catalog_json,
17};
18use serde::{Deserialize, Serialize};
19use std::{
20    fs, io,
21    path::{Path, PathBuf},
22};
23use thiserror::Error as ThisError;
24
25pub const DEFAULT_STALE_AFTER_SECONDS: u64 = 7 * 24 * 60 * 60;
26pub const DEFAULT_REFRESH_LOCK_STALE_SECONDS: u64 = 30 * 60;
27pub const DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT: &str = DEFAULT_MAINNET_ENDPOINT;
28pub const SUBNET_CATALOG_LIST_REPORT_SCHEMA_VERSION: u32 = 1;
29pub const SUBNET_CATALOG_INFO_REPORT_SCHEMA_VERSION: u32 = 1;
30pub const SUBNET_CATALOG_REFRESH_REPORT_SCHEMA_VERSION: u32 = 1;
31const BASE_13_NODE_CYCLES_PER_BILLION_INSTRUCTIONS: u128 = 1_000_000_000;
32const FORMULA_VERSION: &str = "base_13_node_linear_v1";
33
34///
35/// SubnetCatalogCacheRequest
36///
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct SubnetCatalogCacheRequest {
39    pub icp_root: PathBuf,
40    pub network: String,
41}
42
43///
44/// CachedSubnetCatalog
45///
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct CachedSubnetCatalog {
48    pub path: PathBuf,
49    pub catalog: SubnetCatalog,
50}
51
52///
53/// SubnetCatalogFilters
54///
55#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
56pub struct SubnetCatalogFilters {
57    pub kind: Option<SubnetKind>,
58    pub specialization: Option<SubnetSpecialization>,
59    pub geographic_scope: Option<GeographicScope>,
60}
61
62///
63/// SubnetCatalogListRequest
64///
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct SubnetCatalogListRequest {
67    pub cache: SubnetCatalogCacheRequest,
68    pub now_unix_secs: u64,
69    pub stale_after_seconds: u64,
70    pub filters: SubnetCatalogFilters,
71    pub show_ranges: bool,
72    pub range_limit: usize,
73    pub range_offset: usize,
74}
75
76///
77/// SubnetCatalogInfoRequest
78///
79#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct SubnetCatalogInfoRequest {
81    pub cache: SubnetCatalogCacheRequest,
82    pub input: String,
83    pub forced: Option<ResolveAs>,
84    pub resolved_target: Option<ResolvedDeploymentTarget>,
85    pub now_unix_secs: u64,
86    pub stale_after_seconds: u64,
87}
88
89///
90/// SubnetCatalogRefreshRequest
91///
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct SubnetCatalogRefreshRequest {
94    pub cache: SubnetCatalogCacheRequest,
95    pub source_endpoint: String,
96    pub now_unix_secs: u64,
97    pub lock_stale_after_seconds: u64,
98    pub dry_run: bool,
99    pub output_path: Option<PathBuf>,
100}
101
102///
103/// ResolvedDeploymentTarget
104///
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct ResolvedDeploymentTarget {
107    pub canister_principal: String,
108    pub resolved_from: String,
109}
110
111///
112/// CatalogStaleStatus
113///
114#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
115pub struct CatalogStaleStatus {
116    pub catalog_stale: bool,
117    pub stale_reason: String,
118    pub stale_after_seconds: u64,
119    pub fetched_at_unix_secs: Option<u64>,
120    pub age_seconds: Option<u64>,
121}
122
123///
124/// SubnetCatalogListReport
125///
126#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
127pub struct SubnetCatalogListReport {
128    pub schema_version: u32,
129    pub network: String,
130    pub catalog_path: String,
131    pub catalog_schema_version: u32,
132    pub registry_canister_id: String,
133    pub registry_version: u64,
134    pub fetched_at: String,
135    pub catalog_stale: bool,
136    pub stale_reason: String,
137    pub resolver_backend: String,
138    pub subnets: Vec<SubnetCatalogSubnetRow>,
139}
140
141///
142/// SubnetCatalogSubnetRow
143///
144#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
145pub struct SubnetCatalogSubnetRow {
146    pub subnet_principal: String,
147    pub subnet_kind: SubnetKind,
148    pub subnet_kind_source: ClassificationSource,
149    pub subnet_specialization: SubnetSpecialization,
150    pub subnet_specialization_source: ClassificationSource,
151    pub geographic_scope: GeographicScope,
152    pub geographic_scope_source: ClassificationSource,
153    pub subnet_label: String,
154    pub subnet_label_source: ClassificationSource,
155    pub node_count: Option<u32>,
156    pub charges_apply_by_default: bool,
157    pub range_count: usize,
158    pub ranges_shown: usize,
159    pub range_offset: usize,
160    pub range_limit: usize,
161    pub ranges: Vec<RoutingRange>,
162}
163
164///
165/// SubnetCatalogInfoReport
166///
167#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
168pub struct SubnetCatalogInfoReport {
169    pub schema_version: u32,
170    pub input_principal: String,
171    pub resolved_as: String,
172    pub resolved_from: String,
173    pub subnet_principal: String,
174    pub subnet_kind: SubnetKind,
175    pub subnet_kind_source: ClassificationSource,
176    pub subnet_specialization: SubnetSpecialization,
177    pub subnet_specialization_source: ClassificationSource,
178    pub geographic_scope: GeographicScope,
179    pub geographic_scope_source: ClassificationSource,
180    pub subnet_label: String,
181    pub subnet_label_source: ClassificationSource,
182    pub node_count: Option<u32>,
183    pub charges_apply_to_subject: bool,
184    pub charge_applicability_reason: String,
185    pub registry_canister_id: String,
186    pub registry_version: u64,
187    pub catalog_schema_version: u32,
188    pub catalog_path: String,
189    pub fetched_at: String,
190    pub catalog_stale: bool,
191    pub stale_reason: String,
192    pub resolver_backend: String,
193    pub matched_canister_principal: Option<String>,
194    pub matched_routing_range: Option<RoutingRange>,
195    pub cycles_per_billion_instructions: Option<u128>,
196    pub rate_source: Option<String>,
197    pub formula_version: Option<String>,
198}
199
200///
201/// SubnetCatalogRefreshReport
202///
203#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
204pub struct SubnetCatalogRefreshReport {
205    pub schema_version: u32,
206    pub network: String,
207    pub catalog_path: String,
208    pub refresh_lock_path: String,
209    pub output_path: Option<String>,
210    pub registry_canister_id: String,
211    pub registry_version: u64,
212    pub fetched_at: String,
213    pub source_endpoint: String,
214    pub fetched_by: String,
215    pub dry_run: bool,
216    pub wrote_catalog: bool,
217    pub replaced_existing_catalog: bool,
218    pub subnet_count: usize,
219    pub routing_range_count: usize,
220}
221
222///
223/// SubnetCatalogHostError
224///
225#[derive(Debug, ThisError)]
226pub enum SubnetCatalogHostError {
227    #[error(
228        "`canic nns subnet` supports only the mainnet `ic` network in 0.60\n\nThe cached NNS subnet data describes the public Internet Computer mainnet.\nLocal replica subnet discovery is not implemented yet.\n\nTry:\n  canic --network ic nns subnet list"
229    )]
230    UnsupportedNetwork { network: String },
231
232    #[error(
233        "subnet catalog cache is missing at {}\n\nRun `canic nns subnet refresh` to fetch the public Internet Computer mainnet catalog, or populate this path with a valid Canic subnet catalog JSON.",
234        path.display()
235    )]
236    MissingCatalog { path: PathBuf },
237
238    #[error("failed to read subnet catalog at {}: {source}", path.display())]
239    ReadCatalog { path: PathBuf, source: io::Error },
240
241    #[error(
242        "cached subnet catalog network mismatch: path is for {requested}, catalog is for {actual}"
243    )]
244    NetworkMismatch { requested: String, actual: String },
245
246    #[error(
247        "invalid stale duration {value:?}; use positive seconds or a value ending in s, m, h, or d"
248    )]
249    InvalidStaleDuration { value: String },
250
251    #[error("subnet catalog refresh is already in progress; lock exists at {} since unix_ms={started_at_unix_ms}", path.display())]
252    RefreshAlreadyInProgress {
253        path: PathBuf,
254        started_at_unix_ms: u64,
255    },
256
257    #[error("failed to create subnet catalog directory at {}: {source}", path.display())]
258    CreateCatalogDirectory { path: PathBuf, source: io::Error },
259
260    #[error("failed to create refresh lock at {}: {source}", path.display())]
261    CreateRefreshLock { path: PathBuf, source: io::Error },
262
263    #[error("failed to read refresh lock at {}: {source}", path.display())]
264    ReadRefreshLock { path: PathBuf, source: io::Error },
265
266    #[error("failed to parse refresh lock at {}: {source}", path.display())]
267    ParseRefreshLock {
268        path: PathBuf,
269        source: serde_json::Error,
270    },
271
272    #[error("failed to write refresh lock at {}: {source}", path.display())]
273    WriteRefreshLock { path: PathBuf, source: io::Error },
274
275    #[error("failed to remove refresh lock at {}: {source}", path.display())]
276    RemoveRefreshLock { path: PathBuf, source: io::Error },
277
278    #[error("live NNS registry refresh failed: {0}")]
279    RegistryRefresh(#[from] RegistryFetchError),
280
281    #[error("refreshed subnet catalog network mismatch: requested {requested}, fetched {actual}")]
282    RefreshNetworkMismatch { requested: String, actual: String },
283
284    #[error("failed to write subnet catalog temp file at {}: {source}", path.display())]
285    WriteCatalogTemp { path: PathBuf, source: io::Error },
286
287    #[error("failed to sync subnet catalog temp file at {}: {source}", path.display())]
288    SyncCatalogTemp { path: PathBuf, source: io::Error },
289
290    #[error("failed to replace subnet catalog at {} from {}: {source}", catalog_path.display(), temp_path.display())]
291    ReplaceCatalog {
292        temp_path: PathBuf,
293        catalog_path: PathBuf,
294        source: io::Error,
295    },
296
297    #[error("failed to sync subnet catalog directory at {}: {source}", path.display())]
298    SyncCatalogDirectory { path: PathBuf, source: io::Error },
299
300    #[error("failed to write refreshed subnet catalog output at {}: {source}", path.display())]
301    WriteRefreshOutput { path: PathBuf, source: io::Error },
302
303    #[error("failed to sync refreshed subnet catalog output at {}: {source}", path.display())]
304    SyncRefreshOutput { path: PathBuf, source: io::Error },
305
306    #[error(transparent)]
307    Catalog(#[from] CatalogError),
308}
309
310#[must_use]
311pub fn subnet_catalog_path(icp_root: &Path, network: &str) -> PathBuf {
312    icp_root
313        .join(".canic")
314        .join("subnet-catalog")
315        .join(network)
316        .join("catalog.json")
317}
318
319#[must_use]
320pub fn subnet_catalog_refresh_lock_path(icp_root: &Path, network: &str) -> PathBuf {
321    icp_root
322        .join(".canic")
323        .join("subnet-catalog")
324        .join(network)
325        .join("refresh.lock")
326}
327
328pub fn load_cached_subnet_catalog(
329    request: &SubnetCatalogCacheRequest,
330) -> Result<CachedSubnetCatalog, SubnetCatalogHostError> {
331    enforce_mainnet_network(&request.network)?;
332    let path = subnet_catalog_path(&request.icp_root, &request.network);
333    if !path.is_file() {
334        return Err(SubnetCatalogHostError::MissingCatalog { path });
335    }
336    let data = fs::read_to_string(&path).map_err(|source| SubnetCatalogHostError::ReadCatalog {
337        path: path.clone(),
338        source,
339    })?;
340    let catalog = parse_catalog_json(&data)?;
341    if catalog.network != request.network {
342        return Err(SubnetCatalogHostError::NetworkMismatch {
343            requested: request.network.clone(),
344            actual: catalog.network,
345        });
346    }
347    Ok(CachedSubnetCatalog { path, catalog })
348}
349
350pub fn refresh_subnet_catalog(
351    request: &SubnetCatalogRefreshRequest,
352) -> Result<SubnetCatalogRefreshReport, SubnetCatalogHostError> {
353    refresh_subnet_catalog_with_source(request, &LiveNnsRegistryRefreshSource)
354}
355
356fn refresh_subnet_catalog_with_source(
357    request: &SubnetCatalogRefreshRequest,
358    source: &dyn SubnetCatalogRefreshSource,
359) -> Result<SubnetCatalogRefreshReport, SubnetCatalogHostError> {
360    enforce_mainnet_network(&request.cache.network)?;
361    let catalog_path = subnet_catalog_path(&request.cache.icp_root, &request.cache.network);
362    let lock_path =
363        subnet_catalog_refresh_lock_path(&request.cache.icp_root, &request.cache.network);
364    let catalog_dir = catalog_path
365        .parent()
366        .expect("subnet catalog path always has parent")
367        .to_path_buf();
368    create_directory(&catalog_dir).map_err(subnet_cache_error)?;
369    let lock = acquire_refresh_lock(RefreshLockRequest {
370        lock_path: &lock_path,
371        target_path: &catalog_path,
372        network: &request.cache.network,
373        now_unix_secs: request.now_unix_secs,
374        lock_stale_after_seconds: request.lock_stale_after_seconds,
375    })
376    .map_err(subnet_cache_error)?;
377    let replaced_existing_catalog = catalog_path.is_file();
378    let fetched_at = format_utc_timestamp_secs(request.now_unix_secs);
379    let mut fetch_request = MainnetRegistryFetchRequest::new(fetched_at);
380    fetch_request.endpoint.clone_from(&request.source_endpoint);
381    let catalog = source.fetch_catalog(&fetch_request)?;
382    if catalog.network != request.cache.network {
383        return Err(SubnetCatalogHostError::RefreshNetworkMismatch {
384            requested: request.cache.network.clone(),
385            actual: catalog.network,
386        });
387    }
388    catalog.validate()?;
389    let catalog_json = catalog_to_pretty_json(&catalog)?;
390    if let Some(output_path) = &request.output_path {
391        write_text_output(output_path, &catalog_json).map_err(subnet_cache_error)?;
392    }
393    if !request.dry_run {
394        write_text_atomically(&catalog_path, &catalog_json).map_err(subnet_cache_error)?;
395    }
396    lock.release().map_err(subnet_cache_error)?;
397    Ok(SubnetCatalogRefreshReport {
398        schema_version: SUBNET_CATALOG_REFRESH_REPORT_SCHEMA_VERSION,
399        network: catalog.network,
400        catalog_path: catalog_path.display().to_string(),
401        refresh_lock_path: lock_path.display().to_string(),
402        output_path: request
403            .output_path
404            .as_ref()
405            .map(|path| path.display().to_string()),
406        registry_canister_id: catalog.registry_canister_id,
407        registry_version: catalog.registry_version,
408        fetched_at: catalog.fetched_at,
409        source_endpoint: catalog.source_endpoint,
410        fetched_by: catalog.fetched_by,
411        dry_run: request.dry_run,
412        wrote_catalog: !request.dry_run,
413        replaced_existing_catalog,
414        subnet_count: catalog.subnets.len(),
415        routing_range_count: catalog.routing_ranges.len(),
416    })
417}
418
419pub fn build_subnet_catalog_list_report(
420    request: &SubnetCatalogListRequest,
421) -> Result<SubnetCatalogListReport, SubnetCatalogHostError> {
422    let cached = load_cached_subnet_catalog(&request.cache)?;
423    let stale = catalog_stale_status(
424        &cached.catalog,
425        request.now_unix_secs,
426        request.stale_after_seconds,
427    );
428    let subnets = cached
429        .catalog
430        .subnets
431        .iter()
432        .filter(|subnet| subnet_matches_filters(subnet, request.filters))
433        .map(|subnet| subnet_row(&cached.catalog, subnet, request))
434        .collect::<Vec<_>>();
435
436    Ok(SubnetCatalogListReport {
437        schema_version: SUBNET_CATALOG_LIST_REPORT_SCHEMA_VERSION,
438        network: cached.catalog.network,
439        catalog_path: cached.path.display().to_string(),
440        catalog_schema_version: cached.catalog.catalog_schema_version,
441        registry_canister_id: cached.catalog.registry_canister_id,
442        registry_version: cached.catalog.registry_version,
443        fetched_at: cached.catalog.fetched_at,
444        catalog_stale: stale.catalog_stale,
445        stale_reason: stale.stale_reason,
446        resolver_backend: cached.catalog.resolver_backend,
447        subnets,
448    })
449}
450
451pub fn build_subnet_catalog_info_report(
452    request: &SubnetCatalogInfoRequest,
453) -> Result<SubnetCatalogInfoReport, SubnetCatalogHostError> {
454    let cached = load_cached_subnet_catalog(&request.cache)?;
455    let stale = catalog_stale_status(
456        &cached.catalog,
457        request.now_unix_secs,
458        request.stale_after_seconds,
459    );
460    let resolved = if let Some(target) = &request.resolved_target {
461        let mut resolved = cached
462            .catalog
463            .resolve_canister(&target.canister_principal)?;
464        resolved.input_principal.clone_from(&request.input);
465        resolved.resolved_from.clone_from(&target.resolved_from);
466        resolved
467    } else {
468        cached
469            .catalog
470            .resolve_principal_or_prefix(&request.input, request.forced)?
471    };
472    let (charges_apply_to_subject, charge_applicability_reason) =
473        charge_applicability(resolved.resolved_as, resolved.subnet.subnet_kind);
474    let cycles_per_billion_instructions = catalog_cycles_per_billion(&resolved.subnet);
475    let rate_source = cycles_per_billion_instructions
476        .is_some()
477        .then(|| "nns-registry-cache".to_string());
478    let formula_version = cycles_per_billion_instructions
479        .is_some()
480        .then(|| FORMULA_VERSION.to_string());
481
482    Ok(SubnetCatalogInfoReport {
483        schema_version: SUBNET_CATALOG_INFO_REPORT_SCHEMA_VERSION,
484        input_principal: resolved.input_principal,
485        resolved_as: resolved.resolved_as.as_str().to_string(),
486        resolved_from: resolved.resolved_from,
487        subnet_principal: resolved.subnet.subnet_principal,
488        subnet_kind: resolved.subnet.subnet_kind,
489        subnet_kind_source: resolved.subnet.subnet_kind_source,
490        subnet_specialization: resolved.subnet.subnet_specialization,
491        subnet_specialization_source: resolved.subnet.subnet_specialization_source,
492        geographic_scope: resolved.subnet.geographic_scope,
493        geographic_scope_source: resolved.subnet.geographic_scope_source,
494        subnet_label: resolved.subnet.subnet_label,
495        subnet_label_source: resolved.subnet.subnet_label_source,
496        node_count: resolved.subnet.node_count,
497        charges_apply_to_subject,
498        charge_applicability_reason,
499        registry_canister_id: cached.catalog.registry_canister_id,
500        registry_version: cached.catalog.registry_version,
501        catalog_schema_version: cached.catalog.catalog_schema_version,
502        catalog_path: cached.path.display().to_string(),
503        fetched_at: cached.catalog.fetched_at,
504        catalog_stale: stale.catalog_stale,
505        stale_reason: stale.stale_reason,
506        resolver_backend: cached.catalog.resolver_backend,
507        matched_canister_principal: resolved.matched_canister_principal,
508        matched_routing_range: resolved.matched_routing_range,
509        cycles_per_billion_instructions,
510        rate_source,
511        formula_version,
512    })
513}
514
515#[must_use]
516pub fn catalog_stale_status(
517    catalog: &SubnetCatalog,
518    now_unix_secs: u64,
519    stale_after_seconds: u64,
520) -> CatalogStaleStatus {
521    let Some(fetched_at_unix_secs) = parse_utc_timestamp_secs(&catalog.fetched_at) else {
522        return CatalogStaleStatus {
523            catalog_stale: true,
524            stale_reason: "fetched_at_unparseable".to_string(),
525            stale_after_seconds,
526            fetched_at_unix_secs: None,
527            age_seconds: None,
528        };
529    };
530    let Some(age_seconds) = now_unix_secs.checked_sub(fetched_at_unix_secs) else {
531        return CatalogStaleStatus {
532            catalog_stale: false,
533            stale_reason: "fetched_at_in_future".to_string(),
534            stale_after_seconds,
535            fetched_at_unix_secs: Some(fetched_at_unix_secs),
536            age_seconds: None,
537        };
538    };
539    let catalog_stale = age_seconds > stale_after_seconds;
540    CatalogStaleStatus {
541        catalog_stale,
542        stale_reason: if catalog_stale { "expired" } else { "fresh" }.to_string(),
543        stale_after_seconds,
544        fetched_at_unix_secs: Some(fetched_at_unix_secs),
545        age_seconds: Some(age_seconds),
546    }
547}
548
549pub fn parse_stale_after_duration(value: &str) -> Result<u64, SubnetCatalogHostError> {
550    parse_duration_seconds(value).map_err(|_| SubnetCatalogHostError::InvalidStaleDuration {
551        value: value.to_string(),
552    })
553}
554
555fn subnet_cache_error(err: CacheFileError) -> SubnetCatalogHostError {
556    match err {
557        CacheFileError::CreateDirectory { path, source } => {
558            SubnetCatalogHostError::CreateCatalogDirectory { path, source }
559        }
560        CacheFileError::CreateRefreshLock { path, source } => {
561            SubnetCatalogHostError::CreateRefreshLock { path, source }
562        }
563        CacheFileError::ReadRefreshLock { path, source } => {
564            SubnetCatalogHostError::ReadRefreshLock { path, source }
565        }
566        CacheFileError::ParseRefreshLock { path, source } => {
567            SubnetCatalogHostError::ParseRefreshLock { path, source }
568        }
569        CacheFileError::WriteRefreshLock { path, source } => {
570            SubnetCatalogHostError::WriteRefreshLock { path, source }
571        }
572        CacheFileError::RemoveRefreshLock { path, source } => {
573            SubnetCatalogHostError::RemoveRefreshLock { path, source }
574        }
575        CacheFileError::RefreshAlreadyInProgress {
576            path,
577            started_at_unix_ms,
578        } => SubnetCatalogHostError::RefreshAlreadyInProgress {
579            path,
580            started_at_unix_ms,
581        },
582        CacheFileError::WriteTemp { path, source } => {
583            SubnetCatalogHostError::WriteCatalogTemp { path, source }
584        }
585        CacheFileError::SyncTemp { path, source } => {
586            SubnetCatalogHostError::SyncCatalogTemp { path, source }
587        }
588        CacheFileError::Replace {
589            temp_path,
590            target_path,
591            source,
592        } => SubnetCatalogHostError::ReplaceCatalog {
593            temp_path,
594            catalog_path: target_path,
595            source,
596        },
597        CacheFileError::SyncDirectory { path, source } => {
598            SubnetCatalogHostError::SyncCatalogDirectory { path, source }
599        }
600        CacheFileError::WriteOutput { path, source } => {
601            SubnetCatalogHostError::WriteRefreshOutput { path, source }
602        }
603        CacheFileError::SyncOutput { path, source } => {
604            SubnetCatalogHostError::SyncRefreshOutput { path, source }
605        }
606    }
607}
608
609#[must_use]
610pub fn subnet_catalog_list_report_text(report: &SubnetCatalogListReport) -> String {
611    let headers = [
612        "SUBNET", "KIND", "SPEC", "GEO", "NODES", "CHG", "RANGES", "STALE",
613    ];
614    let rows = report
615        .subnets
616        .iter()
617        .map(|subnet| {
618            [
619                compact_principal(&subnet.subnet_principal),
620                subnet.subnet_kind.as_str().to_string(),
621                subnet.subnet_specialization.as_str().to_string(),
622                subnet.geographic_scope.as_str().to_string(),
623                subnet
624                    .node_count
625                    .map_or_else(|| "unknown".to_string(), |count| count.to_string()),
626                yes_no(subnet.charges_apply_by_default).to_string(),
627                subnet.range_count.to_string(),
628                yes_no(report.catalog_stale).to_string(),
629            ]
630        })
631        .collect::<Vec<_>>();
632    let alignments = [
633        ColumnAlign::Left,
634        ColumnAlign::Left,
635        ColumnAlign::Left,
636        ColumnAlign::Left,
637        ColumnAlign::Right,
638        ColumnAlign::Left,
639        ColumnAlign::Right,
640        ColumnAlign::Left,
641    ];
642    let mut lines = Vec::new();
643    lines.push(format!(
644        "catalog: {} version {} stale {}",
645        report.network,
646        report.registry_version,
647        yes_no(report.catalog_stale)
648    ));
649    if rows.is_empty() {
650        lines.push("subnets: none".to_string());
651        return lines.join("\n");
652    }
653    lines.push(render_table(&headers, &rows, &alignments));
654    append_compact_range_lines(report, &mut lines);
655    lines.join("\n")
656}
657
658#[must_use]
659pub fn subnet_catalog_list_report_verbose_text(report: &SubnetCatalogListReport) -> String {
660    let headers = [
661        "SUBNET",
662        "KIND",
663        "SPECIALIZATION",
664        "GEO",
665        "NODES",
666        "CHARGES",
667        "RANGES",
668        "VERSION",
669        "FETCHED_AT",
670        "STALE",
671    ];
672    let rows = report
673        .subnets
674        .iter()
675        .map(|subnet| {
676            [
677                subnet.subnet_principal.clone(),
678                subnet.subnet_kind.as_str().to_string(),
679                subnet.subnet_specialization.as_str().to_string(),
680                subnet.geographic_scope.as_str().to_string(),
681                subnet
682                    .node_count
683                    .map_or_else(|| "unknown".to_string(), |count| count.to_string()),
684                yes_no(subnet.charges_apply_by_default).to_string(),
685                subnet.range_count.to_string(),
686                report.registry_version.to_string(),
687                report.fetched_at.clone(),
688                yes_no(report.catalog_stale).to_string(),
689            ]
690        })
691        .collect::<Vec<_>>();
692    let alignments = [
693        ColumnAlign::Left,
694        ColumnAlign::Left,
695        ColumnAlign::Left,
696        ColumnAlign::Left,
697        ColumnAlign::Right,
698        ColumnAlign::Left,
699        ColumnAlign::Right,
700        ColumnAlign::Right,
701        ColumnAlign::Left,
702        ColumnAlign::Left,
703    ];
704    let mut lines = Vec::new();
705    lines.push(format!("catalog_path: {}", report.catalog_path));
706    lines.push(format!("stale_reason: {}", report.stale_reason));
707    if rows.is_empty() {
708        lines.push("subnets: none".to_string());
709        return lines.join("\n");
710    }
711    lines.push(render_table(&headers, &rows, &alignments));
712    append_range_lines(report, &mut lines);
713    lines.join("\n")
714}
715
716#[must_use]
717pub fn subnet_catalog_info_report_text(report: &SubnetCatalogInfoReport) -> String {
718    let mut lines = Vec::new();
719    lines.push(format!("input_principal: {}", report.input_principal));
720    lines.push(format!("resolved_as: {}", report.resolved_as));
721    lines.push(format!("resolved_from: {}", report.resolved_from));
722    lines.push(format!("subnet_principal: {}", report.subnet_principal));
723    lines.push(format!("subnet_kind: {}", report.subnet_kind.as_str()));
724    lines.push(format!(
725        "subnet_kind_source: {}",
726        report.subnet_kind_source.as_str()
727    ));
728    lines.push(format!(
729        "subnet_specialization: {}",
730        report.subnet_specialization.as_str()
731    ));
732    lines.push(format!(
733        "subnet_specialization_source: {}",
734        report.subnet_specialization_source.as_str()
735    ));
736    lines.push(format!(
737        "geographic_scope: {}",
738        report.geographic_scope.as_str()
739    ));
740    lines.push(format!(
741        "geographic_scope_source: {}",
742        report.geographic_scope_source.as_str()
743    ));
744    lines.push(format!("subnet_label: {}", report.subnet_label));
745    lines.push(format!(
746        "subnet_label_source: {}",
747        report.subnet_label_source.as_str()
748    ));
749    lines.push(format!(
750        "node_count: {}",
751        report
752            .node_count
753            .map_or_else(|| "unknown".to_string(), |count| count.to_string())
754    ));
755    lines.push(format!(
756        "charges_apply_to_subject: {}",
757        yes_no(report.charges_apply_to_subject)
758    ));
759    lines.push(format!(
760        "charge_applicability_reason: {}",
761        report.charge_applicability_reason
762    ));
763    lines.push(format!(
764        "registry_canister_id: {}",
765        report.registry_canister_id
766    ));
767    lines.push(format!("registry_version: {}", report.registry_version));
768    lines.push(format!(
769        "catalog_schema_version: {}",
770        report.catalog_schema_version
771    ));
772    lines.push(format!("catalog_path: {}", report.catalog_path));
773    lines.push(format!("fetched_at: {}", report.fetched_at));
774    lines.push(format!("catalog_stale: {}", yes_no(report.catalog_stale)));
775    lines.push(format!("stale_reason: {}", report.stale_reason));
776    lines.push(format!("resolver_backend: {}", report.resolver_backend));
777    if let Some(canister) = &report.matched_canister_principal {
778        lines.push(format!("matched_canister_principal: {canister}"));
779    }
780    if let Some(range) = &report.matched_routing_range {
781        lines.push(format!(
782            "matched_routing_range: {}..{}",
783            range.start_canister_id, range.end_canister_id
784        ));
785    }
786    lines.push(format!(
787        "cycles_per_billion_instructions: {}",
788        report
789            .cycles_per_billion_instructions
790            .map_or_else(|| "not_applicable".to_string(), |cycles| cycles.to_string())
791    ));
792    if let Some(rate_source) = &report.rate_source {
793        lines.push(format!("rate_source: {rate_source}"));
794    }
795    if let Some(formula_version) = &report.formula_version {
796        lines.push(format!("formula_version: {formula_version}"));
797    }
798    lines.join("\n")
799}
800
801#[must_use]
802pub fn subnet_catalog_refresh_report_text(report: &SubnetCatalogRefreshReport) -> String {
803    [
804        format!("network: {}", report.network),
805        format!("catalog_path: {}", report.catalog_path),
806        format!("refresh_lock_path: {}", report.refresh_lock_path),
807        format!("registry_canister_id: {}", report.registry_canister_id),
808        format!("registry_version: {}", report.registry_version),
809        format!("fetched_at: {}", report.fetched_at),
810        format!("source_endpoint: {}", report.source_endpoint),
811        format!("fetched_by: {}", report.fetched_by),
812        format!("dry_run: {}", yes_no(report.dry_run)),
813        format!("wrote_catalog: {}", yes_no(report.wrote_catalog)),
814        format!(
815            "replaced_existing_catalog: {}",
816            yes_no(report.replaced_existing_catalog)
817        ),
818        format!("subnet_count: {}", report.subnet_count),
819        format!("routing_range_count: {}", report.routing_range_count),
820    ]
821    .join("\n")
822}
823
824fn enforce_mainnet_network(network: &str) -> Result<(), SubnetCatalogHostError> {
825    if network == MAINNET_NETWORK {
826        return Ok(());
827    }
828    Err(SubnetCatalogHostError::UnsupportedNetwork {
829        network: network.to_string(),
830    })
831}
832
833trait SubnetCatalogRefreshSource {
834    fn fetch_catalog(
835        &self,
836        request: &MainnetRegistryFetchRequest,
837    ) -> Result<SubnetCatalog, SubnetCatalogHostError>;
838}
839
840///
841/// LiveNnsRegistryRefreshSource
842///
843struct LiveNnsRegistryRefreshSource;
844
845impl SubnetCatalogRefreshSource for LiveNnsRegistryRefreshSource {
846    fn fetch_catalog(
847        &self,
848        request: &MainnetRegistryFetchRequest,
849    ) -> Result<SubnetCatalog, SubnetCatalogHostError> {
850        Ok(fetch_mainnet_subnet_catalog(request)?)
851    }
852}
853
854fn subnet_matches_filters(subnet: &SubnetInfo, filters: SubnetCatalogFilters) -> bool {
855    filters.kind.is_none_or(|kind| subnet.subnet_kind == kind)
856        && filters
857            .specialization
858            .is_none_or(|specialization| subnet.subnet_specialization == specialization)
859        && filters
860            .geographic_scope
861            .is_none_or(|scope| subnet.geographic_scope == scope)
862}
863
864fn subnet_row(
865    catalog: &SubnetCatalog,
866    subnet: &SubnetInfo,
867    request: &SubnetCatalogListRequest,
868) -> SubnetCatalogSubnetRow {
869    let ranges = catalog.routing_ranges_for_subnet(&subnet.subnet_principal);
870    let range_count = ranges.len();
871    let shown_ranges = if request.show_ranges {
872        ranges
873            .into_iter()
874            .skip(request.range_offset)
875            .take(request.range_limit)
876            .cloned()
877            .collect::<Vec<_>>()
878    } else {
879        Vec::new()
880    };
881    SubnetCatalogSubnetRow {
882        subnet_principal: subnet.subnet_principal.clone(),
883        subnet_kind: subnet.subnet_kind,
884        subnet_kind_source: subnet.subnet_kind_source,
885        subnet_specialization: subnet.subnet_specialization,
886        subnet_specialization_source: subnet.subnet_specialization_source,
887        geographic_scope: subnet.geographic_scope,
888        geographic_scope_source: subnet.geographic_scope_source,
889        subnet_label: subnet.subnet_label.clone(),
890        subnet_label_source: subnet.subnet_label_source,
891        node_count: subnet.node_count,
892        charges_apply_by_default: subnet.charges_apply_by_default,
893        range_count,
894        ranges_shown: shown_ranges.len(),
895        range_offset: request.range_offset,
896        range_limit: request.range_limit,
897        ranges: shown_ranges,
898    }
899}
900
901fn charge_applicability(subject: ResolvedSubnetSubject, kind: SubnetKind) -> (bool, String) {
902    match kind {
903        SubnetKind::Application => (true, "charged_user_canister_subnet".to_string()),
904        SubnetKind::System if subject == ResolvedSubnetSubject::Subnet => {
905            (false, "system_subnet_core_canister".to_string())
906        }
907        SubnetKind::System => (false, "system_subnet_unknown_subject".to_string()),
908        SubnetKind::Unknown => (false, "unknown_subnet_type".to_string()),
909    }
910}
911
912fn catalog_cycles_per_billion(subnet: &SubnetInfo) -> Option<u128> {
913    if subnet.subnet_kind != SubnetKind::Application {
914        return None;
915    }
916    let node_count = u128::from(subnet.node_count?);
917    if node_count == 0 {
918        return None;
919    }
920    Some(ceil_div(
921        BASE_13_NODE_CYCLES_PER_BILLION_INSTRUCTIONS * node_count,
922        13,
923    ))
924}
925
926const fn ceil_div(numerator: u128, denominator: u128) -> u128 {
927    numerator.div_ceil(denominator)
928}
929
930fn append_range_lines(report: &SubnetCatalogListReport, lines: &mut Vec<String>) {
931    for subnet in &report.subnets {
932        if subnet.ranges.is_empty() {
933            continue;
934        }
935        lines.push(format!("ranges for {}:", subnet.subnet_principal));
936        for range in &subnet.ranges {
937            lines.push(format!(
938                "  {}..{}",
939                range.start_canister_id, range.end_canister_id
940            ));
941        }
942        if subnet.ranges_shown < subnet.range_count {
943            lines.push(format!(
944                "  showing {} of {} ranges; use --range-limit or --format json",
945                subnet.ranges_shown, subnet.range_count
946            ));
947        }
948    }
949}
950
951fn append_compact_range_lines(report: &SubnetCatalogListReport, lines: &mut Vec<String>) {
952    for subnet in &report.subnets {
953        if subnet.ranges.is_empty() {
954            continue;
955        }
956        lines.push(format!(
957            "ranges for {}:",
958            compact_principal(&subnet.subnet_principal)
959        ));
960        for range in &subnet.ranges {
961            lines.push(format!(
962                "  {}..{}",
963                compact_principal(&range.start_canister_id),
964                compact_principal(&range.end_canister_id)
965            ));
966        }
967        if subnet.ranges_shown < subnet.range_count {
968            lines.push(format!(
969                "  showing {} of {} ranges; use --range-limit or --format json",
970                subnet.ranges_shown, subnet.range_count
971            ));
972        }
973    }
974}
975
976fn compact_principal(value: &str) -> String {
977    value.chars().take(5).collect()
978}
979
980fn parse_utc_timestamp_secs(value: &str) -> Option<u64> {
981    let value = value.strip_suffix('Z')?;
982    let (date, time) = value.split_once('T')?;
983    let mut date_parts = date.split('-');
984    let year = date_parts.next()?.parse::<i64>().ok()?;
985    let month = date_parts.next()?.parse::<u32>().ok()?;
986    let day = date_parts.next()?.parse::<u32>().ok()?;
987    if date_parts.next().is_some() {
988        return None;
989    }
990    let mut time_parts = time.split(':');
991    let hour = time_parts.next()?.parse::<u32>().ok()?;
992    let minute = time_parts.next()?.parse::<u32>().ok()?;
993    let second = time_parts.next()?.parse::<u32>().ok()?;
994    if time_parts.next().is_some()
995        || !(1..=12).contains(&month)
996        || !(1..=31).contains(&day)
997        || hour > 23
998        || minute > 59
999        || second > 59
1000    {
1001        return None;
1002    }
1003    let days = days_from_civil(year, month, day)?;
1004    let seconds = days
1005        .checked_mul(86_400)?
1006        .checked_add(i64::from(hour) * 3_600)?
1007        .checked_add(i64::from(minute) * 60)?
1008        .checked_add(i64::from(second))?;
1009    u64::try_from(seconds).ok()
1010}
1011
1012pub(crate) fn format_utc_timestamp_secs(value: u64) -> String {
1013    let days = i64::try_from(value / 86_400).unwrap_or(i64::MAX);
1014    let seconds_of_day = value % 86_400;
1015    let (year, month, day) = civil_from_days(days);
1016    let hour = seconds_of_day / 3_600;
1017    let minute = (seconds_of_day % 3_600) / 60;
1018    let second = seconds_of_day % 60;
1019    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
1020}
1021
1022fn civil_from_days(days: i64) -> (i64, u32, u32) {
1023    let days = days + 719_468;
1024    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
1025    let day_of_era = days - era * 146_097;
1026    let year_of_era =
1027        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
1028    let mut year = year_of_era + era * 400;
1029    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
1030    let month_prime = (5 * day_of_year + 2) / 153;
1031    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
1032    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
1033    year += i64::from(month <= 2);
1034    (
1035        year,
1036        u32::try_from(month).expect("civil month is in u32 range"),
1037        u32::try_from(day).expect("civil day is in u32 range"),
1038    )
1039}
1040
1041fn days_from_civil(year: i64, month: u32, day: u32) -> Option<i64> {
1042    let month = i64::from(month);
1043    let day = i64::from(day);
1044    let year = year - i64::from(month <= 2);
1045    let era = if year >= 0 { year } else { year - 399 } / 400;
1046    let year_of_era = year - era * 400;
1047    let month_prime = month + if month > 2 { -3 } else { 9 };
1048    let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
1049    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
1050    era.checked_mul(146_097)?
1051        .checked_add(day_of_era)?
1052        .checked_sub(719_468)
1053}
1054
1055const fn yes_no(value: bool) -> &'static str {
1056    if value { "yes" } else { "no" }
1057}
1058
1059#[cfg(test)]
1060mod tests;