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 {
1061    use super::*;
1062    use crate::test_support::temp_dir;
1063    use canic_subnet_catalog::{
1064        CATALOG_SCHEMA_VERSION, ClassificationSource, GeographicScope,
1065        MAINNET_REGISTRY_CANISTER_ID, SubnetSpecialization,
1066    };
1067
1068    const SUBNET_A: &str = "rwlgt-iiaaa-aaaaa-aaaaa-cai";
1069    const SUBNET_B: &str = "aaaaa-aa";
1070    const CANISTER_A: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";
1071
1072    #[test]
1073    fn catalog_path_lives_outside_deployment_state() {
1074        let root = PathBuf::from("/tmp/canic-project");
1075
1076        let path = subnet_catalog_path(&root, MAINNET_NETWORK);
1077
1078        assert_eq!(
1079            path,
1080            PathBuf::from("/tmp/canic-project/.canic/subnet-catalog/ic/catalog.json")
1081        );
1082        assert!(!path.display().to_string().contains("/deployments/"));
1083        assert!(!path.display().to_string().contains("/fleets/"));
1084    }
1085
1086    #[test]
1087    fn load_cached_catalog_rejects_non_mainnet_network() {
1088        let root = temp_dir("canic-subnet-host-network");
1089        let request = SubnetCatalogCacheRequest {
1090            icp_root: root.clone(),
1091            network: "local".to_string(),
1092        };
1093
1094        let err = load_cached_subnet_catalog(&request).expect_err("local rejected");
1095
1096        let _ = fs::remove_dir_all(root);
1097        std::assert_matches!(err, SubnetCatalogHostError::UnsupportedNetwork { .. });
1098    }
1099
1100    #[test]
1101    fn missing_catalog_error_explains_cached_only_slice() {
1102        let root = temp_dir("canic-subnet-host-missing");
1103        let request = SubnetCatalogCacheRequest {
1104            icp_root: root.clone(),
1105            network: MAINNET_NETWORK.to_string(),
1106        };
1107
1108        let err = load_cached_subnet_catalog(&request).expect_err("cache missing");
1109        let message = err.to_string();
1110
1111        let _ = fs::remove_dir_all(root);
1112        assert!(message.contains("Run `canic nns subnet refresh`"));
1113        assert!(message.contains("public Internet Computer mainnet catalog"));
1114        assert!(message.contains("canic nns subnet refresh"));
1115    }
1116
1117    #[test]
1118    fn list_report_loads_cached_catalog_and_caps_ranges() {
1119        let root = temp_dir("canic-subnet-host-list");
1120        write_catalog(&root, fixture_catalog());
1121        let request = list_request(&root);
1122
1123        let report = build_subnet_catalog_list_report(&request).expect("list report");
1124        let text = subnet_catalog_list_report_text(&report);
1125
1126        let _ = fs::remove_dir_all(root);
1127        assert_eq!(report.subnets.len(), 2);
1128        assert_eq!(report.subnets[0].range_count, 2);
1129        assert_eq!(report.subnets[0].ranges_shown, 1);
1130        assert!(text.contains("SUBNET"));
1131        assert!(text.contains("SPEC"));
1132        assert!(!text.contains("SPECIALIZATION"));
1133        for subnet in &report.subnets {
1134            assert!(text.contains(&compact_principal(&subnet.subnet_principal)));
1135            assert!(!text.contains(&subnet.subnet_principal));
1136        }
1137        assert!(!text.contains("FETCHED_AT"));
1138        assert!(text.contains("showing 1 of 2 ranges"));
1139    }
1140
1141    #[test]
1142    fn list_report_verbose_text_keeps_full_metadata() {
1143        let root = temp_dir("canic-subnet-host-list-verbose");
1144        write_catalog(&root, fixture_catalog());
1145        let request = list_request(&root);
1146
1147        let report = build_subnet_catalog_list_report(&request).expect("list report");
1148        let text = subnet_catalog_list_report_verbose_text(&report);
1149
1150        let _ = fs::remove_dir_all(root);
1151        assert!(text.contains("catalog_path:"));
1152        assert!(text.contains("SPECIALIZATION"));
1153        assert!(text.contains("FETCHED_AT"));
1154        assert!(text.contains(SUBNET_A));
1155    }
1156
1157    #[test]
1158    fn info_report_resolves_canister_and_marks_application_chargeable() {
1159        let root = temp_dir("canic-subnet-host-info");
1160        write_catalog(&root, fixture_catalog());
1161        let request = info_request(&root, CANISTER_A);
1162
1163        let report = build_subnet_catalog_info_report(&request).expect("info report");
1164
1165        let _ = fs::remove_dir_all(root);
1166        assert_eq!(report.resolved_as, "canister");
1167        assert_eq!(report.subnet_principal, SUBNET_A);
1168        assert!(report.charges_apply_to_subject);
1169        assert_eq!(
1170            report.charge_applicability_reason,
1171            "charged_user_canister_subnet"
1172        );
1173        assert_eq!(report.cycles_per_billion_instructions, Some(2_615_384_616));
1174    }
1175
1176    #[test]
1177    fn info_report_resolves_unique_subnet_prefix() {
1178        let root = temp_dir("canic-subnet-host-info-subnet-prefix");
1179        write_catalog(&root, fixture_catalog());
1180        let request = info_request(&root, "rwl");
1181
1182        let report = build_subnet_catalog_info_report(&request).expect("info report");
1183
1184        let _ = fs::remove_dir_all(root);
1185        assert_eq!(report.input_principal, "rwl");
1186        assert_eq!(report.resolved_as, "subnet");
1187        assert_eq!(report.resolved_from, "subnet_principal_prefix");
1188        assert_eq!(report.subnet_principal, SUBNET_A);
1189        assert_eq!(report.matched_canister_principal, None);
1190    }
1191
1192    #[test]
1193    fn info_report_rejects_canister_prefix() {
1194        let root = temp_dir("canic-subnet-host-info-canister-prefix");
1195        write_catalog(&root, fixture_catalog());
1196        let request = info_request(&root, "ryj");
1197
1198        let err = build_subnet_catalog_info_report(&request).expect_err("canister prefix rejected");
1199
1200        let _ = fs::remove_dir_all(root);
1201        std::assert_matches!(
1202            err,
1203            SubnetCatalogHostError::Catalog(CatalogError::PrincipalPrefixNotFound { prefix })
1204                if prefix == "ryj"
1205        );
1206    }
1207
1208    #[test]
1209    fn system_subnet_has_no_catalog_rate() {
1210        let root = temp_dir("canic-subnet-host-system");
1211        let mut catalog = fixture_catalog();
1212        catalog.subnets[0].subnet_kind = SubnetKind::System;
1213        catalog.subnets[0].charges_apply_by_default = false;
1214        write_catalog(&root, catalog);
1215        let request = info_request(&root, CANISTER_A);
1216
1217        let report = build_subnet_catalog_info_report(&request).expect("info report");
1218
1219        let _ = fs::remove_dir_all(root);
1220        assert!(!report.charges_apply_to_subject);
1221        assert_eq!(
1222            report.charge_applicability_reason,
1223            "system_subnet_unknown_subject"
1224        );
1225        assert_eq!(report.cycles_per_billion_instructions, None);
1226    }
1227
1228    #[test]
1229    fn stale_status_is_deterministic() {
1230        let catalog = fixture_catalog();
1231        let fresh = catalog_stale_status(&catalog, 1_780_531_300, 200);
1232        let stale = catalog_stale_status(&catalog, 1_780_531_501, 200);
1233
1234        assert!(!fresh.catalog_stale);
1235        assert!(stale.catalog_stale);
1236    }
1237
1238    #[test]
1239    fn stale_duration_accepts_units() {
1240        assert_eq!(parse_stale_after_duration("7d").expect("days"), 604_800);
1241        assert_eq!(parse_stale_after_duration("2h").expect("hours"), 7_200);
1242        assert_eq!(parse_stale_after_duration("30m").expect("minutes"), 1_800);
1243        assert_eq!(parse_stale_after_duration("90s").expect("seconds"), 90);
1244        assert_eq!(parse_stale_after_duration("42").expect("bare"), 42);
1245        std::assert_matches!(
1246            parse_stale_after_duration("0d"),
1247            Err(SubnetCatalogHostError::InvalidStaleDuration { .. })
1248        );
1249    }
1250
1251    #[test]
1252    fn refresh_writes_catalog_atomically_and_removes_lock() {
1253        let root = temp_dir("canic-subnet-host-refresh");
1254        let mut catalog = fixture_catalog();
1255        catalog.registry_version = 987_654;
1256        catalog.fetched_at = "1970-01-01T00:00:00Z".to_string();
1257        catalog.source_endpoint = DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT.to_string();
1258        let source = FixtureRefreshSource::ok(catalog);
1259        let request = refresh_request(&root);
1260
1261        let report =
1262            refresh_subnet_catalog_with_source(&request, &source).expect("refresh catalog");
1263        let cached = load_cached_subnet_catalog(&cache_request(&root)).expect("cached catalog");
1264        let lock_path = PathBuf::from(&report.refresh_lock_path);
1265
1266        let _ = fs::remove_dir_all(root);
1267        assert!(report.wrote_catalog);
1268        assert!(!report.replaced_existing_catalog);
1269        assert_eq!(report.registry_version, 987_654);
1270        assert_eq!(cached.catalog.registry_version, 987_654);
1271        assert!(!lock_path.exists());
1272    }
1273
1274    #[test]
1275    fn refresh_dry_run_writes_output_without_replacing_cache() {
1276        let root = temp_dir("canic-subnet-host-refresh-dry-run");
1277        let mut catalog = fixture_catalog();
1278        catalog.fetched_at = "1970-01-01T00:00:00Z".to_string();
1279        catalog.source_endpoint = DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT.to_string();
1280        let output_path = root.join("catalog-export.json");
1281        let source = FixtureRefreshSource::ok(catalog);
1282        let mut request = refresh_request(&root);
1283        request.dry_run = true;
1284        request.output_path = Some(output_path.clone());
1285
1286        let report = refresh_subnet_catalog_with_source(&request, &source).expect("dry-run");
1287
1288        assert!(!report.wrote_catalog);
1289        assert!(!subnet_catalog_path(&request.cache.icp_root, MAINNET_NETWORK).exists());
1290        assert!(output_path.exists());
1291        let _ = fs::remove_dir_all(root);
1292    }
1293
1294    #[test]
1295    fn refresh_failure_preserves_existing_catalog_and_removes_lock() {
1296        let root = temp_dir("canic-subnet-host-refresh-failure");
1297        write_catalog(&root, fixture_catalog());
1298        let source = FixtureRefreshSource::err();
1299        let request = refresh_request(&root);
1300
1301        let err = refresh_subnet_catalog_with_source(&request, &source).expect_err("refresh fails");
1302        let cached = load_cached_subnet_catalog(&cache_request(&root)).expect("cached catalog");
1303        let lock_path = subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK);
1304
1305        std::assert_matches!(err, SubnetCatalogHostError::InvalidStaleDuration { .. });
1306        assert_eq!(cached.catalog.registry_version, 123_456);
1307        assert!(!lock_path.exists());
1308        let _ = fs::remove_dir_all(root);
1309    }
1310
1311    #[test]
1312    fn refresh_existing_fresh_lock_fails_fast() {
1313        let root = temp_dir("canic-subnet-host-refresh-locked");
1314        let request = refresh_request(&root);
1315        let lock_path = subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK);
1316        write_refresh_lock_for_test(&lock_path, &request, request.now_unix_secs * 1_000);
1317
1318        let err = refresh_subnet_catalog_with_source(&request, &FixtureRefreshSource::err())
1319            .expect_err("lock held");
1320
1321        let _ = fs::remove_dir_all(root);
1322        std::assert_matches!(err, SubnetCatalogHostError::RefreshAlreadyInProgress { .. });
1323    }
1324
1325    #[test]
1326    fn refresh_removes_stale_lock_and_retries_once() {
1327        let root = temp_dir("canic-subnet-host-refresh-stale-lock");
1328        let mut catalog = fixture_catalog();
1329        catalog.fetched_at = "1970-01-01T00:00:00Z".to_string();
1330        catalog.source_endpoint = DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT.to_string();
1331        let source = FixtureRefreshSource::ok(catalog);
1332        let request = refresh_request(&root);
1333        let lock_path = subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK);
1334        let stale_started_at =
1335            (request.now_unix_secs - request.lock_stale_after_seconds - 1) * 1_000;
1336        write_refresh_lock_for_test(&lock_path, &request, stale_started_at);
1337
1338        let report =
1339            refresh_subnet_catalog_with_source(&request, &source).expect("stale lock removed");
1340
1341        assert!(report.wrote_catalog);
1342        assert!(!lock_path.exists());
1343        let _ = fs::remove_dir_all(root);
1344    }
1345
1346    #[test]
1347    fn utc_timestamp_formatter_is_deterministic() {
1348        assert_eq!(format_utc_timestamp_secs(0), "1970-01-01T00:00:00Z");
1349        assert_eq!(
1350            format_utc_timestamp_secs(1_780_531_200),
1351            "2026-06-04T00:00:00Z"
1352        );
1353    }
1354
1355    fn list_request(root: &Path) -> SubnetCatalogListRequest {
1356        SubnetCatalogListRequest {
1357            cache: cache_request(root),
1358            now_unix_secs: 1_780_531_300,
1359            stale_after_seconds: DEFAULT_STALE_AFTER_SECONDS,
1360            filters: SubnetCatalogFilters::default(),
1361            show_ranges: true,
1362            range_limit: 1,
1363            range_offset: 0,
1364        }
1365    }
1366
1367    fn info_request(root: &Path, input: &str) -> SubnetCatalogInfoRequest {
1368        SubnetCatalogInfoRequest {
1369            cache: cache_request(root),
1370            input: input.to_string(),
1371            forced: None,
1372            resolved_target: None,
1373            now_unix_secs: 1_780_531_300,
1374            stale_after_seconds: DEFAULT_STALE_AFTER_SECONDS,
1375        }
1376    }
1377
1378    fn cache_request(root: &Path) -> SubnetCatalogCacheRequest {
1379        SubnetCatalogCacheRequest {
1380            icp_root: root.to_path_buf(),
1381            network: MAINNET_NETWORK.to_string(),
1382        }
1383    }
1384
1385    fn write_catalog(root: &Path, catalog: SubnetCatalog) {
1386        let path = subnet_catalog_path(root, MAINNET_NETWORK);
1387        fs::create_dir_all(path.parent().expect("catalog parent")).expect("create parent");
1388        fs::write(
1389            path,
1390            serde_json::to_vec_pretty(&catalog).expect("serialize catalog"),
1391        )
1392        .expect("write catalog");
1393    }
1394
1395    fn refresh_request(root: &Path) -> SubnetCatalogRefreshRequest {
1396        SubnetCatalogRefreshRequest {
1397            cache: cache_request(root),
1398            source_endpoint: DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT.to_string(),
1399            now_unix_secs: 1_780_531_200,
1400            lock_stale_after_seconds: DEFAULT_REFRESH_LOCK_STALE_SECONDS,
1401            dry_run: false,
1402            output_path: None,
1403        }
1404    }
1405
1406    fn write_refresh_lock_for_test(
1407        lock_path: &Path,
1408        request: &SubnetCatalogRefreshRequest,
1409        started_at_unix_ms: u64,
1410    ) {
1411        fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("create parent");
1412        let lock = serde_json::json!({
1413            "schema_version": 1,
1414            "network": request.cache.network.clone(),
1415            "pid": 12345,
1416            "started_at_unix_ms": started_at_unix_ms,
1417            "target_path": subnet_catalog_path(&request.cache.icp_root, &request.cache.network)
1418                .display()
1419                .to_string(),
1420        });
1421        fs::write(
1422            lock_path,
1423            serde_json::to_vec_pretty(&lock).expect("serialize lock"),
1424        )
1425        .expect("write lock");
1426    }
1427
1428    ///
1429    /// FixtureRefreshSource
1430    ///
1431    struct FixtureRefreshSource {
1432        catalog: Option<SubnetCatalog>,
1433        fail: bool,
1434    }
1435
1436    impl FixtureRefreshSource {
1437        fn ok(catalog: SubnetCatalog) -> Self {
1438            Self {
1439                catalog: Some(catalog),
1440                fail: false,
1441            }
1442        }
1443
1444        fn err() -> Self {
1445            Self {
1446                catalog: None,
1447                fail: true,
1448            }
1449        }
1450    }
1451
1452    impl SubnetCatalogRefreshSource for FixtureRefreshSource {
1453        fn fetch_catalog(
1454            &self,
1455            _request: &MainnetRegistryFetchRequest,
1456        ) -> Result<SubnetCatalog, SubnetCatalogHostError> {
1457            if self.fail {
1458                return Err(SubnetCatalogHostError::InvalidStaleDuration {
1459                    value: "fixture".to_string(),
1460                });
1461            }
1462            Ok(self.catalog.clone().expect("fixture catalog"))
1463        }
1464    }
1465
1466    fn fixture_catalog() -> SubnetCatalog {
1467        SubnetCatalog {
1468            catalog_schema_version: CATALOG_SCHEMA_VERSION,
1469            network: MAINNET_NETWORK.to_string(),
1470            registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
1471            registry_version: 123_456,
1472            fetched_at: "2026-06-04T00:00:00Z".to_string(),
1473            fetched_by: "fixture".to_string(),
1474            source_endpoint: "https://icp-api.io".to_string(),
1475            resolver_backend: "local-nns-subnet-catalog".to_string(),
1476            subnets: vec![
1477                SubnetInfo {
1478                    subnet_principal: SUBNET_A.to_string(),
1479                    subnet_kind: SubnetKind::Application,
1480                    subnet_kind_source: ClassificationSource::Registry,
1481                    subnet_specialization: SubnetSpecialization::Fiduciary,
1482                    subnet_specialization_source: ClassificationSource::Curated,
1483                    geographic_scope: GeographicScope::Global,
1484                    geographic_scope_source: ClassificationSource::Curated,
1485                    subnet_label: "fiduciary".to_string(),
1486                    subnet_label_source: ClassificationSource::Curated,
1487                    node_count: Some(34),
1488                    charges_apply_by_default: true,
1489                },
1490                SubnetInfo {
1491                    subnet_principal: SUBNET_B.to_string(),
1492                    subnet_kind: SubnetKind::System,
1493                    subnet_kind_source: ClassificationSource::Registry,
1494                    subnet_specialization: SubnetSpecialization::None,
1495                    subnet_specialization_source: ClassificationSource::Curated,
1496                    geographic_scope: GeographicScope::Global,
1497                    geographic_scope_source: ClassificationSource::Curated,
1498                    subnet_label: "system".to_string(),
1499                    subnet_label_source: ClassificationSource::Curated,
1500                    node_count: Some(13),
1501                    charges_apply_by_default: false,
1502                },
1503            ],
1504            routing_ranges: vec![
1505                RoutingRange {
1506                    start_canister_id: CANISTER_A.to_string(),
1507                    end_canister_id: CANISTER_A.to_string(),
1508                    subnet_principal: SUBNET_A.to_string(),
1509                },
1510                RoutingRange {
1511                    start_canister_id: "rrkah-fqaaa-aaaaa-aaaaq-cai".to_string(),
1512                    end_canister_id: "rrkah-fqaaa-aaaaa-aaaaq-cai".to_string(),
1513                    subnet_principal: SUBNET_A.to_string(),
1514                },
1515                RoutingRange {
1516                    start_canister_id: "r7inp-6aaaa-aaaaa-aaabq-cai".to_string(),
1517                    end_canister_id: "r7inp-6aaaa-aaaaa-aaabq-cai".to_string(),
1518                    subnet_principal: SUBNET_B.to_string(),
1519                },
1520            ],
1521        }
1522    }
1523}