Skip to main content

ic_query/cloud_engine/
text.rs

1//! Module: cloud_engine::text
2//!
3//! Responsibility: render compact human-facing CloudEngine reports.
4//! Does not own: report construction, JSON output, live calls, or process output.
5//! Boundary: formats cycle amounts only for text while JSON retains raw decimal fields.
6
7use crate::text_value::REPORT_TABLE_OPTIONS;
8use ic_host_text::{
9    escape::terminal_text,
10    table::{Alignment, render},
11};
12
13#[cfg(all(feature = "cloud-engine-host", feature = "subnet-catalog-host"))]
14use super::CloudEngineListReport;
15use super::{CloudEngineOperatorReport, CloudEnginePricesReport, CloudEngineReportContext};
16use crate::{
17    human_quantity::decimal_cycle_count_text,
18    text_value::{optional_text, yes_no},
19};
20
21/// Render the Registry CloudEngine inventory and separate operator-binding observations.
22#[cfg(all(feature = "cloud-engine-host", feature = "subnet-catalog-host"))]
23#[must_use]
24pub fn cloud_engine_list_report_text(report: &CloudEngineListReport) -> String {
25    let mut lines = list_context_lines(report);
26    lines.push(String::new());
27    lines.push("CloudEngine subnets".to_string());
28    if report.cloud_engines.is_empty() {
29        lines.push("none".to_string());
30        return lines.join("\n");
31    }
32    lines.push(list_table(report));
33    append_lookup_failures(report, &mut lines);
34    lines.join("\n")
35}
36
37#[cfg(all(feature = "cloud-engine-host", feature = "subnet-catalog-host"))]
38fn list_context_lines(report: &CloudEngineListReport) -> Vec<String> {
39    vec![
40        format!("network: {}", terminal_text(&report.network)),
41        format!(
42            "registry_authority: {}",
43            terminal_text(&report.registry_authority)
44        ),
45        format!("registry_canister_id: {}", report.registry_canister_id),
46        format!("registry_version: {}", report.registry_version),
47        format!("registry_assurance: {}", report.registry_assurance.as_str()),
48        format!(
49            "registry_source_endpoints: {}",
50            report
51                .registry_source_endpoints
52                .iter()
53                .map(|endpoint| terminal_text(endpoint))
54                .collect::<Vec<_>>()
55                .join(", ")
56        ),
57        format!(
58            "catalog_fetched_at: {}",
59            terminal_text(&report.catalog_fetched_at)
60        ),
61        format!(
62            "catalog_cache_disposition: {}",
63            report.catalog_cache_disposition.as_str()
64        ),
65        format!("catalog_stale: {}", yes_no(report.catalog_stale)),
66        String::new(),
67        format!(
68            "control_plane_authority: {}",
69            terminal_text(&report.control_plane_authority)
70        ),
71        format!(
72            "control_plane_canister_id: {}",
73            report.control_plane_canister_id
74        ),
75        format!(
76            "control_plane_source_endpoint: {}",
77            terminal_text(&report.control_plane_source_endpoint)
78        ),
79        format!(
80            "control_plane_fetched_at: {}",
81            terminal_text(&report.control_plane_fetched_at)
82        ),
83        format!(
84            "control_plane_certified: {}",
85            yes_no(report.control_plane_certified)
86        ),
87        format!(
88            "control_plane_point_in_time_guaranteed: {}",
89            yes_no(report.control_plane_point_in_time_guaranteed)
90        ),
91        format!(
92            "control_plane_lookup_attempt_count: {}",
93            report.control_plane_lookup_attempt_count
94        ),
95        format!(
96            "operator_bindings: {} resolved, {} absent, {} failed",
97            report.operator_binding_count,
98            report.missing_operator_binding_count,
99            report.operator_lookup_failure_count
100        ),
101    ]
102}
103
104#[cfg(all(feature = "cloud-engine-host", feature = "subnet-catalog-host"))]
105fn list_table(report: &CloudEngineListReport) -> String {
106    let headers = ["Label", "Subnet", "Nodes", "Binding", "Operator"];
107    let alignments = [
108        Alignment::Left,
109        Alignment::Left,
110        Alignment::Right,
111        Alignment::Left,
112        Alignment::Left,
113    ];
114    let rows = report
115        .cloud_engines
116        .iter()
117        .map(|row| {
118            [
119                terminal_text(&row.subnet_label).into_owned(),
120                row.subnet_id.clone(),
121                row.node_count
122                    .map_or_else(|| "unknown".to_string(), |count| count.to_string()),
123                match &row.operator {
124                    Ok(Some(_)) => "resolved",
125                    Ok(None) => "absent",
126                    Err(_) => "failed",
127                }
128                .to_string(),
129                row.operator
130                    .as_ref()
131                    .ok()
132                    .and_then(Option::as_ref)
133                    .map_or_else(
134                        || "-".to_string(),
135                        ic_platform::candid::contract::Principal::to_text,
136                    ),
137            ]
138        })
139        .collect::<Vec<_>>();
140    render(&headers, &rows, &alignments, REPORT_TABLE_OPTIONS)
141}
142
143#[cfg(all(feature = "cloud-engine-host", feature = "subnet-catalog-host"))]
144fn append_lookup_failures(report: &CloudEngineListReport, lines: &mut Vec<String>) {
145    let failures = report
146        .cloud_engines
147        .iter()
148        .filter_map(|row| row.operator.as_ref().err().map(|error| (row, error)))
149        .collect::<Vec<_>>();
150    if !failures.is_empty() {
151        lines.push(String::new());
152        lines.push("Operator lookup failures".to_string());
153        lines.extend(
154            failures
155                .into_iter()
156                .map(|(row, error)| format!("  {}: {}", row.subnet_id, terminal_text(error))),
157        );
158    }
159}
160
161/// Render one CloudEngine Subnet-to-operator report.
162#[must_use]
163pub fn cloud_engine_operator_report_text(report: &CloudEngineOperatorReport) -> String {
164    let mut lines = context_lines(&report.context);
165    lines.extend([
166        format!("subnet_id: {}", report.subnet_id),
167        format!(
168            "operator_binding_present: {}",
169            yes_no(report.operator_binding_present)
170        ),
171        format!(
172            "operator_canister_id: {}",
173            optional_text(report.operator_canister_id.as_ref())
174        ),
175        format!(
176            "engine_owner: {}",
177            optional_text(report.engine_owner.as_ref())
178        ),
179        format!(
180            "platform_admin: {}",
181            optional_text(report.platform_admin.as_ref())
182        ),
183        format!(
184            "caffeine_enabled: {}",
185            report
186                .caffeine_enabled
187                .map_or_else(|| "-".to_string(), |enabled| yes_no(enabled).to_string())
188        ),
189        format!(
190            "claimed_domain_count: {}",
191            report
192                .claimed_domain_count
193                .map_or_else(|| "-".to_string(), |count| count.to_string())
194        ),
195    ]);
196
197    if let Some(domains) = report.claimed_domains.as_ref()
198        && !domains.is_empty()
199    {
200        lines.push(String::new());
201        lines.push("Claimed domains".to_string());
202        lines.extend(
203            domains
204                .iter()
205                .map(|domain| format!("  {}", terminal_text(domain))),
206        );
207    }
208    lines.join("\n")
209}
210
211/// Render one bounded CloudEngine marketplace price report.
212#[must_use]
213pub fn cloud_engine_prices_report_text(report: &CloudEnginePricesReport) -> String {
214    let mut lines = context_lines(&report.context);
215    lines.extend([
216        format!("network_fee: {}", report.network_fee),
217        format!("price_count: {}", report.price_count),
218    ]);
219
220    if !report.prices.is_empty() {
221        let headers = [
222            "Key",
223            "Node type",
224            "Data center",
225            "Provider",
226            "Net/month",
227            "Gross/month",
228            "Updated (ns)",
229        ];
230        let alignments = [
231            Alignment::Left,
232            Alignment::Left,
233            Alignment::Left,
234            Alignment::Left,
235            Alignment::Right,
236            Alignment::Right,
237            Alignment::Right,
238        ];
239        let rows = report
240            .prices
241            .iter()
242            .map(|row| {
243                [
244                    row.key.clone(),
245                    row.node_type.to_string(),
246                    row.data_center_id.as_deref().map_or_else(
247                        || "-".to_string(),
248                        |value| terminal_text(value).into_owned(),
249                    ),
250                    row.provider_id.clone().unwrap_or_else(|| "-".to_string()),
251                    decimal_cycle_count_text(&row.net_cycles_per_month),
252                    decimal_cycle_count_text(&row.gross_cycles_per_month),
253                    row.updated_at_unix_nanos.to_string(),
254                ]
255            })
256            .collect::<Vec<_>>();
257        lines.push(String::new());
258        lines.push("Marketplace prices".to_string());
259        lines.push(render(&headers, &rows, &alignments, REPORT_TABLE_OPTIONS));
260    }
261    lines.join("\n")
262}
263
264fn context_lines(context: &CloudEngineReportContext) -> Vec<String> {
265    vec![
266        format!("network: {}", terminal_text(&context.network)),
267        format!("authority: {}", terminal_text(&context.authority)),
268        format!("engine_canister_id: {}", context.engine_canister_id),
269        format!("fetched_at: {}", terminal_text(&context.fetched_at)),
270        format!(
271            "source_endpoint: {}",
272            terminal_text(&context.source_endpoint)
273        ),
274        format!("fetched_by: {}", terminal_text(&context.fetched_by)),
275        format!("certified: {}", yes_no(context.certified)),
276        format!(
277            "point_in_time_guaranteed: {}",
278            yes_no(context.point_in_time_guaranteed)
279        ),
280        format!("query_call_count: {}", context.query_call_count),
281    ]
282}