Skip to main content

ic_query/ic/
text.rs

1//! Module: ic::text
2//!
3//! Responsibility: render IC Dashboard reports as human-facing text.
4//! Does not own: REST calls, report construction, JSON serialization, or command output.
5//! Boundary: keeps raw values intact in JSON while making nullable text fields readable.
6
7use crate::{
8    human_quantity::decimal_cycle_rate_text,
9    ic::{
10        IcBoundaryNodeDataCentersReport, IcCanisterCountReport, IcCanisterFilters,
11        IcCanisterPageReport, IcCanisterReport, IcDailyStatsReport, IcDashboardReportProvenance,
12        IcIcrcIndexedCountReport, IcIcrcTokenValueReport, IcIcrcTotalSupplyReport, IcMetricKind,
13        IcMetricReport,
14    },
15    text_value::{optional_text, sanitize_text, yes_no},
16};
17
18/// Render one official boundary-node data-center report as human-facing text.
19#[must_use]
20pub fn ic_boundary_node_data_centers_report_text(
21    report: &IcBoundaryNodeDataCentersReport,
22) -> String {
23    let mut lines = report_header(&report.provenance);
24    lines.extend([
25        format!("data_center_count: {}", report.data_center_count),
26        format!("total_node_count: {}", report.total_node_count),
27    ]);
28    append_report_footer(&mut lines, &report.provenance);
29
30    if !report.rows.is_empty() {
31        lines.push(String::new());
32        lines.push("boundary_node_data_centers:".to_string());
33        lines.extend(report.rows.iter().map(|row| {
34            format!(
35                "  {}  name={}  owner={}  region={}  latitude={}  longitude={}  nodes={}",
36                sanitize_text(&row.dc_id),
37                sanitize_text(&row.name),
38                sanitize_text(&row.owner),
39                sanitize_text(&row.region),
40                sanitize_text(&row.latitude),
41                sanitize_text(&row.longitude),
42                sanitize_text(&row.total_nodes),
43            )
44        }));
45    }
46    lines.join("\n")
47}
48
49/// Render one bounded official Dashboard daily-statistics report as human-facing text.
50#[must_use]
51pub fn ic_daily_stats_report_text(report: &IcDailyStatsReport) -> String {
52    let mut lines = report_header(&report.provenance);
53    lines.extend([
54        format!("start_unix_secs: {}", report.query.start_unix_secs),
55        format!("end_unix_secs: {}", report.query.end_unix_secs),
56        format!("returned_day_count: {}", report.returned_day_count),
57    ]);
58    append_report_footer(&mut lines, &report.provenance);
59
60    if !report.rows.is_empty() {
61        lines.push(String::new());
62        lines.push("daily_stats:".to_string());
63        lines.extend(report.rows.iter().map(|row| {
64            format!(
65                "  {}  timestamp={}  avg_total={}  avg_update={}  avg_query={}  max_total={}  max_update={}  max_query={}  blocks_avg={}",
66                sanitize_text(&row.day),
67                row.timestamp_unix_secs,
68                sanitize_text(&row.average_transactions_per_second),
69                sanitize_text(&row.average_update_transactions_per_second),
70                sanitize_text(&row.average_query_transactions_per_second),
71                sanitize_text(&row.max_total_transactions_per_second),
72                sanitize_text(&row.max_update_transactions_per_second),
73                sanitize_text(&row.max_query_transactions_per_second),
74                sanitize_text(&row.blocks_per_second_average),
75            )
76        }));
77    }
78    lines.join("\n")
79}
80
81/// Render one bounded official Dashboard metric report as human-facing text.
82#[must_use]
83pub fn ic_metric_report_text(report: &IcMetricReport) -> String {
84    let mut lines = report_header(&report.provenance);
85    lines.extend([
86        format!("metric: {}", report.query.metric),
87        format!("start_unix_secs: {}", report.query.start_unix_secs),
88        format!("end_unix_secs: {}", report.query.end_unix_secs),
89        format!("step_secs: {}", report.query.step_secs),
90        format!("returned_series_count: {}", report.returned_series_count),
91        format!(
92            "returned_observation_count: {}",
93            report.returned_observation_count
94        ),
95    ]);
96    append_report_footer(&mut lines, &report.provenance);
97
98    for series in &report.series {
99        lines.push(String::new());
100        lines.push(format!("{}:", sanitize_text(&series.name)));
101        lines.extend(series.observations.iter().map(|observation| {
102            format!(
103                "  {}  {}",
104                observation.timestamp_unix_secs,
105                metric_observation_text(report.query.metric, &observation.value)
106            )
107        }));
108    }
109    lines.join("\n")
110}
111
112/// Render one official ICRC indexed-count report as human-facing text.
113#[must_use]
114pub fn icrc_indexed_count_report_text(report: &IcIcrcIndexedCountReport) -> String {
115    let mut lines = report_header(&report.provenance);
116    lines.extend([
117        format!(
118            "ledger_canister_id: {}",
119            sanitize_text(&report.ledger_canister_id)
120        ),
121        format!("kind: {}", report.kind),
122        format!("total: {}", report.total),
123    ]);
124    append_report_footer(&mut lines, &report.provenance);
125    lines.join("\n")
126}
127
128/// Render one bounded official ICRC token-value series as human-facing text.
129#[must_use]
130pub fn icrc_token_value_report_text(report: &IcIcrcTokenValueReport) -> String {
131    let mut lines = report_header(&report.provenance);
132    lines.extend([
133        format!(
134            "ledger_canister_id: {}",
135            sanitize_text(&report.ledger_canister_id)
136        ),
137        format!("start_unix_secs: {}", report.query.start_unix_secs),
138        format!("end_unix_secs: {}", report.query.end_unix_secs),
139        format!("limit: {}", report.query.limit),
140        format!("returned_row_count: {}", report.returned_row_count),
141        format!("limit_reached: {}", yes_no(report.limit_reached)),
142    ]);
143    append_report_footer(&mut lines, &report.provenance);
144
145    if !report.rows.is_empty() {
146        lines.push(String::new());
147        lines.push("token_values:".to_string());
148        lines.extend(report.rows.iter().map(|row| {
149            format!(
150                "  {}  price={}  volume_24h={}  price_usd={}  volume_24h_usd={}  source={}  source_url={}",
151                row.timestamp_unix_secs,
152                optional_text(row.price.as_ref()),
153                optional_text(row.volume_24h.as_ref()),
154                optional_text(row.price_usd.as_ref()),
155                optional_text(row.volume_24h_usd.as_ref()),
156                optional_text(row.source.as_ref()),
157                optional_text(row.source_url.as_ref()),
158            )
159        }));
160    }
161    lines.join("\n")
162}
163
164/// Render one bounded official ICRC total-supply series as human-facing text.
165#[must_use]
166pub fn icrc_total_supply_report_text(report: &IcIcrcTotalSupplyReport) -> String {
167    let mut lines = report_header(&report.provenance);
168    lines.extend([
169        format!(
170            "ledger_canister_id: {}",
171            sanitize_text(&report.ledger_canister_id)
172        ),
173        format!("start_unix_secs: {}", report.query.start_unix_secs),
174        format!("end_unix_secs: {}", report.query.end_unix_secs),
175        format!("step_secs: {}", report.query.step_secs),
176        format!(
177            "requested_observation_limit: {}",
178            report.requested_observation_limit
179        ),
180        format!(
181            "returned_observation_count: {}",
182            report.returned_observation_count
183        ),
184    ]);
185    append_report_footer(&mut lines, &report.provenance);
186
187    if !report.observations.is_empty() {
188        lines.push(String::new());
189        lines.push("total_supply_base_units:".to_string());
190        lines.extend(report.observations.iter().map(|observation| {
191            format!(
192                "  {}  {}",
193                observation.timestamp_unix_secs,
194                sanitize_text(&observation.total_supply_base_units)
195            )
196        }));
197    }
198    lines.join("\n")
199}
200
201fn metric_observation_text(metric: IcMetricKind, value: &str) -> String {
202    if metric == IcMetricKind::CycleBurnRate {
203        decimal_cycle_rate_text(value)
204    } else {
205        sanitize_text(value)
206    }
207}
208
209/// Render one official Dashboard canister report as human-facing text.
210#[must_use]
211pub fn ic_canister_report_text(report: &IcCanisterReport) -> String {
212    let mut lines = report_header(&report.provenance);
213    lines.extend([
214        format!("canister_id: {}", report.canister_id),
215        format!("dashboard_id: {}", report.dashboard_id),
216        format!("name: {}", text_or_dash(&report.name)),
217        format!(
218            "canister_type: {}",
219            report
220                .canister_type
221                .as_deref()
222                .map_or_else(|| "-".to_string(), text_or_dash)
223        ),
224        format!("subnet_id: {}", report.subnet_id),
225        format!("controller_count: {}", report.controllers.len()),
226        format!("language: {}", text_or_dash(&report.language)),
227        format!("module_hash: {}", text_or_dash(&report.module_hash)),
228        format!(
229            "dashboard_updated_at: {}",
230            sanitize_text(&report.dashboard_updated_at)
231        ),
232        format!(
233            "upgrade_history_available: {}",
234            yes_no(report.upgrades.is_some())
235        ),
236        format!(
237            "upgrade_count: {}",
238            report
239                .upgrade_count
240                .map_or_else(|| "-".to_string(), |count| count.to_string())
241        ),
242    ]);
243    append_report_footer(&mut lines, &report.provenance);
244
245    if !report.controllers.is_empty() {
246        lines.push(String::new());
247        lines.push("controllers:".to_string());
248        lines.extend(
249            report
250                .controllers
251                .iter()
252                .map(|controller| format!("  {controller}")),
253        );
254    }
255
256    if let Some(latest) = report
257        .upgrades
258        .as_ref()
259        .and_then(|upgrades| upgrades.first())
260    {
261        lines.push(String::new());
262        lines.push("latest_upgrade:".to_string());
263        lines.push(format!("  proposal_id: {}", latest.proposal_id));
264        lines.push(format!(
265            "  executed_timestamp_seconds: {}",
266            latest.executed_timestamp_seconds
267        ));
268        lines.push(format!("  module_hash: {}", latest.module_hash));
269    }
270
271    lines.join("\n")
272}
273
274/// Render one official Dashboard canister-count report as human-facing text.
275#[must_use]
276pub fn ic_canister_count_report_text(report: &IcCanisterCountReport) -> String {
277    let mut lines = report_header(&report.provenance);
278    lines.push(format!("total: {}", report.total));
279    append_filters(&mut lines, &report.filters);
280    append_report_footer(&mut lines, &report.provenance);
281    lines.join("\n")
282}
283
284/// Render one bounded official Dashboard canister page as human-facing text.
285#[must_use]
286pub fn ic_canister_page_report_text(report: &IcCanisterPageReport) -> String {
287    let mut lines = report_header(&report.provenance);
288    lines.extend([
289        format!("returned_count: {}", report.returned_count),
290        format!("requested_limit: {}", report.requested_limit),
291        format!(
292            "after: {}",
293            report.after.as_deref().map_or("-", |value| value)
294        ),
295        format!(
296            "before: {}",
297            report.before.as_deref().map_or("-", |value| value)
298        ),
299        format!(
300            "previous_cursor: {}",
301            report.previous_cursor.as_deref().map_or("-", |value| value)
302        ),
303        format!(
304            "next_cursor: {}",
305            report.next_cursor.as_deref().map_or("-", |value| value)
306        ),
307    ]);
308    append_filters(&mut lines, &report.filters);
309    append_report_footer(&mut lines, &report.provenance);
310
311    if !report.rows.is_empty() {
312        lines.push(String::new());
313        lines.push("canisters:".to_string());
314        lines.extend(report.rows.iter().map(|row| {
315            format!(
316                "  {}  name={}  type={}  subnet={}  controllers={}  language={}  updated={}",
317                row.canister_id,
318                text_or_dash(&row.name),
319                row.canister_type
320                    .as_deref()
321                    .map_or_else(|| "-".to_string(), text_or_dash),
322                row.subnet_id,
323                row.controllers.len(),
324                text_or_dash(&row.language),
325                sanitize_text(&row.dashboard_updated_at),
326            )
327        }));
328    }
329    lines.join("\n")
330}
331
332fn report_header(provenance: &IcDashboardReportProvenance) -> Vec<String> {
333    vec![
334        format!("network: {}", sanitize_text(&provenance.network)),
335        format!("authority: {}", sanitize_text(&provenance.authority)),
336    ]
337}
338
339fn append_report_footer(lines: &mut Vec<String>, provenance: &IcDashboardReportProvenance) {
340    lines.extend([
341        format!("certified: {}", yes_no(provenance.certified)),
342        format!(
343            "point_in_time_guaranteed: {}",
344            yes_no(provenance.point_in_time_guaranteed)
345        ),
346        format!("fetched_at: {}", sanitize_text(&provenance.fetched_at)),
347        format!(
348            "source_endpoint: {}",
349            sanitize_text(&provenance.source_endpoint)
350        ),
351    ]);
352}
353
354fn append_filters(lines: &mut Vec<String>, filters: &IcCanisterFilters) {
355    if let Some(has_name) = filters.has_name {
356        lines.push(format!("filter.has_name: {}", yes_no(has_name)));
357    }
358    if let Some(subnet_id) = filters.subnet_id.as_deref() {
359        lines.push(format!("filter.subnet_id: {subnet_id}"));
360    }
361    if let Some(controller_id) = filters.controller_id.as_deref() {
362        lines.push(format!("filter.controller_id: {controller_id}"));
363    }
364    if !filters.languages.is_empty() {
365        lines.push(format!(
366            "filter.languages: {}",
367            filters
368                .languages
369                .iter()
370                .map(|value| sanitize_text(value))
371                .collect::<Vec<_>>()
372                .join(",")
373        ));
374    }
375    if !filters.canister_types.is_empty() {
376        lines.push(format!(
377            "filter.canister_types: {}",
378            filters
379                .canister_types
380                .iter()
381                .map(|value| sanitize_text(value))
382                .collect::<Vec<_>>()
383                .join(",")
384        ));
385    }
386    if let Some(query) = filters.query.as_deref() {
387        lines.push(format!("filter.query: {}", sanitize_text(query)));
388    }
389}
390
391fn text_or_dash(value: &str) -> String {
392    if value.is_empty() {
393        "-".to_string()
394    } else {
395        sanitize_text(value)
396    }
397}