use super::ListCommandError;
use crate::support::registry_tree::{RegistryRow, visible_rows};
use canic_host::{
registry::RegistryEntry,
table::{ColumnAlign, render_separator, render_table, render_table_row, table_widths},
};
use std::collections::{BTreeMap, BTreeSet};
const COLUMN_GAP: &str = " ";
const MODULE_PREFIX_CHARS: usize = 8;
const MODULE_VARIANT_COLOR: &str = "\x1b[38;5;179m";
const COLOR_RESET: &str = "\x1b[0m";
const MODULE_COLUMN_INDEX: usize = 1;
pub(super) const ROLE_HEADER: &str = "ROLE";
pub(super) const KIND_HEADER: &str = "KIND";
pub(super) const CAPS_HEADER: &str = "CAPS";
pub(super) const AUTO_HEADER: &str = "AUTO";
pub(super) const TOPUP_HEADER: &str = "TOPUP";
pub(super) const METRICS_HEADER: &str = "METRICS";
pub(super) const CANISTER_HEADER: &str = "CANISTER_ID";
pub(super) const MODULE_HEADER: &str = "MODULE";
pub(super) const MODULE_HASH_HEADER: &str = "MODULE_HASH";
pub(super) const READY_HEADER: &str = "READY";
pub(super) const CANIC_HEADER: &str = "CANIC";
pub(super) const WASM_HEADER: &str = "WASM_GZ";
pub(super) const CYCLES_HEADER: &str = "CYCLES";
const CONFIG_HEADERS: [&str; 6] = [
ROLE_HEADER,
KIND_HEADER,
AUTO_HEADER,
CAPS_HEADER,
METRICS_HEADER,
TOPUP_HEADER,
];
const CONFIG_ALIGNMENTS: [ColumnAlign; 6] = [ColumnAlign::Left; 6];
const REGISTRY_HEADERS: [&str; 7] = [
ROLE_HEADER,
MODULE_HEADER,
CANISTER_HEADER,
READY_HEADER,
CANIC_HEADER,
WASM_HEADER,
CYCLES_HEADER,
];
const REGISTRY_VERBOSE_HEADERS: [&str; 7] = [
ROLE_HEADER,
MODULE_HASH_HEADER,
CANISTER_HEADER,
READY_HEADER,
CANIC_HEADER,
WASM_HEADER,
CYCLES_HEADER,
];
const REGISTRY_ALIGNMENTS: [ColumnAlign; 7] = [
ColumnAlign::Left,
ColumnAlign::Left,
ColumnAlign::Left,
ColumnAlign::Left,
ColumnAlign::Left,
ColumnAlign::Right,
ColumnAlign::Right,
];
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct ListTitle {
pub(super) fleet: String,
pub(super) network: String,
}
impl ListTitle {
#[must_use]
pub(super) fn render(&self) -> String {
format!("Fleet: {} (network {})", self.fleet, self.network)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum ReadyStatus {
Ready,
NotReady,
Error,
}
impl ReadyStatus {
const fn label(self) -> &'static str {
match self {
Self::Ready => "yes",
Self::NotReady => "no",
Self::Error => "error",
}
}
}
pub(super) struct RegistryColumnData<'a> {
pub(super) readiness: &'a BTreeMap<String, ReadyStatus>,
pub(super) canic_versions: &'a BTreeMap<String, String>,
pub(super) module_hashes: &'a BTreeMap<String, String>,
pub(super) wasm_sizes: &'a BTreeMap<String, String>,
pub(super) cycles: &'a BTreeMap<String, String>,
pub(super) full_module_hashes: bool,
pub(super) color_module_variants: bool,
}
pub(super) fn render_registry_tree(
registry: &[RegistryEntry],
canister: Option<&str>,
columns: &RegistryColumnData<'_>,
) -> Result<String, ListCommandError> {
let rows = visible_rows(registry, canister)?;
Ok(render_registry_table(&rows, columns))
}
pub(super) fn render_list_output(
title: &ListTitle,
registry: &[RegistryEntry],
canister: Option<&str>,
columns: &RegistryColumnData<'_>,
missing_roles: &[String],
) -> Result<String, ListCommandError> {
let mut output = format!(
"{}\n\n{}",
title.render(),
render_registry_tree(registry, canister, columns)?
);
if !missing_roles.is_empty() {
output.push_str("\n\nMissing roles: ");
output.push_str(&missing_roles.join(", "));
}
Ok(output)
}
pub(super) fn render_config_output(
title: &ListTitle,
rows: &[ConfigRoleRow],
verbose: bool,
) -> String {
format!(
"{}\n\n{}",
title.render(),
render_config_table(rows, verbose)
)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct ConfigRoleRow {
pub(super) role: String,
pub(super) kind: String,
pub(super) capabilities: String,
pub(super) auto_create: String,
pub(super) topup: String,
pub(super) metrics: String,
pub(super) details: Vec<String>,
}
fn render_config_table(rows: &[ConfigRoleRow], verbose: bool) -> String {
let table_rows = config_table_rows(rows);
let widths = table_widths(&CONFIG_HEADERS, &table_rows);
let mut lines = Vec::new();
lines.push(render_table_row(
&CONFIG_HEADERS,
&widths,
&CONFIG_ALIGNMENTS,
));
lines.push(render_separator(&widths));
for (row, table_row) in rows.iter().zip(table_rows.iter()) {
lines.push(render_table_row(table_row, &widths, &CONFIG_ALIGNMENTS));
if verbose {
lines.extend(row.details.iter().map(|detail| format!(" - {detail}")));
}
}
lines.join("\n")
}
fn config_table_rows(rows: &[ConfigRoleRow]) -> Vec<[String; 6]> {
rows.iter()
.map(|row| {
[
row.role.clone(),
row.kind.clone(),
row.auto_create.clone(),
row.capabilities.clone(),
row.metrics.clone(),
row.topup.clone(),
]
})
.collect()
}
fn render_registry_table(rows: &[RegistryRow<'_>], columns: &RegistryColumnData<'_>) -> String {
let table_rows = registry_table_rows(rows, columns);
let headers = if columns.full_module_hashes {
®ISTRY_VERBOSE_HEADERS
} else {
®ISTRY_HEADERS
};
if !columns.color_module_variants {
return render_table(headers, &table_rows, ®ISTRY_ALIGNMENTS);
}
let widths = table_widths(headers, &table_rows);
let variants = module_variant_flags(rows, columns.module_hashes);
let mut lines = Vec::with_capacity(table_rows.len() + 2);
lines.push(render_table_row(headers, &widths, ®ISTRY_ALIGNMENTS));
lines.push(render_separator(&widths));
lines.extend(
table_rows
.iter()
.zip(variants)
.map(|(row, variant)| render_registry_row(row, &widths, variant)),
);
lines.join("\n")
}
fn registry_table_rows(
rows: &[RegistryRow<'_>],
columns: &RegistryColumnData<'_>,
) -> Vec<[String; 7]> {
let mut table_rows = Vec::with_capacity(rows.len());
for row in rows {
let ready = columns
.readiness
.get(&row.entry.pid)
.map_or("unknown", |status| status.label());
let canic_version = columns
.canic_versions
.get(&row.entry.pid)
.cloned()
.unwrap_or_else(|| "-".to_string());
let wasm_size = row
.entry
.role
.as_deref()
.and_then(|role| columns.wasm_sizes.get(role))
.cloned()
.unwrap_or_else(|| "-".to_string());
let cycle_balance = columns
.cycles
.get(&row.entry.pid)
.cloned()
.unwrap_or_else(|| "-".to_string());
table_rows.push([
role_label(row),
module_hash_label(row, columns.module_hashes, columns.full_module_hashes),
canister_label(row),
ready.to_string(),
canic_version,
wasm_size,
cycle_balance,
]);
}
table_rows
}
fn render_registry_row(row: &[String; 7], widths: &[usize; 7], color_module: bool) -> String {
widths
.iter()
.zip(REGISTRY_ALIGNMENTS)
.enumerate()
.map(|(index, (width, alignment))| {
let value = &row[index];
let cell = match alignment {
ColumnAlign::Left => format!("{value:<width$}"),
ColumnAlign::Right => format!("{value:>width$}"),
};
if color_module && index == MODULE_COLUMN_INDEX {
format!("{MODULE_VARIANT_COLOR}{cell}{COLOR_RESET}")
} else {
cell
}
})
.collect::<Vec<_>>()
.join(COLUMN_GAP)
.trim_end()
.to_string()
}
fn module_variant_flags(
rows: &[RegistryRow<'_>],
module_hashes: &BTreeMap<String, String>,
) -> Vec<bool> {
let baselines = role_module_baselines(rows, module_hashes);
let distinct = role_distinct_module_hashes(rows, module_hashes);
rows.iter()
.map(|row| {
row_role(row)
.and_then(|role| {
row_module_hash(row, module_hashes)
.zip(baselines.get(role).map(String::as_str))
.zip(distinct.get(role))
})
.is_some_and(|((hash, baseline), role_hashes)| {
role_hashes.len() > 1 && hash != baseline
})
})
.collect()
}
fn role_module_baselines(
rows: &[RegistryRow<'_>],
module_hashes: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut baselines = BTreeMap::new();
for row in rows {
if let Some((role, hash)) = row_role(row).zip(row_module_hash(row, module_hashes)) {
baselines
.entry(role.to_string())
.or_insert_with(|| hash.to_string());
}
}
baselines
}
fn role_distinct_module_hashes(
rows: &[RegistryRow<'_>],
module_hashes: &BTreeMap<String, String>,
) -> BTreeMap<String, BTreeSet<String>> {
let mut distinct = BTreeMap::<String, BTreeSet<String>>::new();
for row in rows {
if let Some((role, hash)) = row_role(row).zip(row_module_hash(row, module_hashes)) {
distinct
.entry(role.to_string())
.or_default()
.insert(hash.to_string());
}
}
distinct
}
fn row_role<'a>(row: &RegistryRow<'a>) -> Option<&'a str> {
row.entry.role.as_deref().filter(|role| !role.is_empty())
}
fn row_module_hash<'a>(
row: &RegistryRow<'_>,
module_hashes: &'a BTreeMap<String, String>,
) -> Option<&'a str> {
module_hashes
.get(&row.entry.pid)
.map(String::as_str)
.filter(|hash| !hash.is_empty())
}
#[cfg(test)]
pub(super) fn render_registry_table_row(row: &[impl AsRef<str>], widths: &[usize; 7]) -> String {
render_table_row(row, widths, ®ISTRY_ALIGNMENTS)
}
#[cfg(test)]
pub(super) fn render_registry_separator(widths: &[usize; 7]) -> String {
render_separator(widths)
}
fn canister_label(row: &RegistryRow<'_>) -> String {
row.entry.pid.clone()
}
fn role_label(row: &RegistryRow<'_>) -> String {
let role = row.entry.role.as_deref().filter(|role| !role.is_empty());
let label = match role {
Some(role) => role.to_string(),
None => "unknown".to_string(),
};
format!("{}{}", row.tree_prefix, label)
}
fn module_hash_label(
row: &RegistryRow<'_>,
module_hashes: &BTreeMap<String, String>,
full: bool,
) -> String {
let Some(hash) = module_hashes
.get(&row.entry.pid)
.filter(|hash| !hash.is_empty())
else {
return "-".to_string();
};
if full {
return hash.clone();
}
hash.chars().take(MODULE_PREFIX_CHARS).collect()
}