use ignition_core::actions::apicall::ApiCallOutcome;
use ignition_core::actions::backup::{BackupDownloadResult, BackupRestoreResult};
use ignition_core::actions::connections::ConnectionsResult;
use ignition_core::actions::diagnostics::{BundleDownloadResult, BundleStatusWire};
use ignition_core::actions::eam::{
EamDeleteResult, EamHistoryResult, EamLifecycleResult, EamModifyResult, EamTaskCreateResult,
EamTaskDetailResult, EamTaskForceResult, EamTasksResult,
};
use ignition_core::actions::gan::GanStatusResult;
use ignition_core::actions::inspect::{MetricsResult, ModulesResult, StatusResult};
use ignition_core::actions::license::LicenseStatusResult;
use ignition_core::actions::lint::LintResult;
use ignition_core::actions::logs::{
DownloadResult, LogPage, ResetResult, SetLevelResult, TailResult,
};
use ignition_core::actions::projects::{
ExportDecodedResult, ExportResult, ImportResult, ProjectCopyResult, ProjectDeleteResult,
ProjectDiffResult, ProjectRenameResult, ProjectSetResult, ProjectSyncResult, ProjectsResult,
};
use ignition_core::actions::redundancy::RedundancyStatusResult;
use ignition_core::actions::resources::{
ResourceDeleteResult, ResourceGetResult, ResourcePutResult, ResourcesResult,
};
use ignition_core::actions::rig::{
ModuleUninstallResult, RestoreResult, RigDownResult, RigLogsResult, RigResetResult,
RigStatusResult, RigUpResult, SnapshotResult, TrialResetResult, TrialStatusResult,
};
use ignition_core::actions::script::ScriptRunResult;
use ignition_core::actions::sessions::{SessionsResult, TerminateResult};
use ignition_core::actions::tags::{
BrowseRow, LossReport, TagBrowseFromExportResult, TagProvidersResult, TagsAlarmsAckResult,
TagsAlarmsActiveResult, TagsAlarmsHistoryResult, TagsBrowseResult, TagsConfigGetResult,
TagsExportResult, TagsHistoryQueryResult, TagsReadResult, TagsUdtDefResult, TagsUdtTypesResult,
history_summary,
};
use ignition_core::actions::testing::TestingRunResult;
use ignition_core::actions::webdev::{WebdevDeployResult, WebdevStatusResult};
use ignition_core::actions::workspace::{
CheckoutOutcome, PushOutcome, StatusKind, WorkspaceStatus,
};
use ignition_core::client::logs::LogEntry;
use ignition_core::client::query::ListEnvelope;
use ignition_core::error::CoreError;
use ignition_core::output::render_failure;
use ignition_core::rig::OrphanedModule;
use crate::ActionOutput;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RenderMode {
Human,
PrettyJson,
CompactJson,
}
impl RenderMode {
pub fn resolve(json: bool, compact: bool) -> Self {
if compact {
Self::CompactJson
} else if json {
Self::PrettyJson
} else {
Self::Human
}
}
fn is_json(self) -> bool {
matches!(self, Self::PrettyJson | Self::CompactJson)
}
}
pub fn render_ok(out: &ActionOutput, profile: Option<&str>, mode: RenderMode) {
if let ActionOutput::Completions { shell } = out {
print!("{}", crate::completions::completions(*shell));
return;
}
if matches!(out, ActionOutput::LogsTail(_) | ActionOutput::RigLogs(_)) {
return;
}
if let ActionOutput::TagsExport(result) = out
&& result.stdout
{
let bytes = result
.raw
.as_deref()
.or(result.payload.as_deref())
.unwrap_or_default();
print!("{bytes}");
if mode == RenderMode::Human && result.format != "json" {
eprintln!(
"exported {} path(s) → stdout ({}, {} tag(s))",
result.paths.len(),
result.format,
result.tag_count
);
if let Some(report) = &result.loss_report {
render_csv_loss_warnings(report);
}
}
return;
}
#[cfg(feature = "tui")]
if matches!(out, ActionOutput::TuiExited) {
return;
}
match mode {
RenderMode::Human => render_human(out, profile),
RenderMode::PrettyJson => {
let rendered = out.render_json(profile, false);
println!("{rendered}");
}
RenderMode::CompactJson => {
let rendered = out.render_json(profile, true);
println!("{rendered}");
}
}
}
pub fn render_error(err: &CoreError, profile: Option<&str>, mode: RenderMode) {
if mode.is_json() {
let envelope = err.envelope(profile);
let rendered = render_failure(&envelope, mode == RenderMode::CompactJson);
eprintln!("{rendered}");
} else {
if let Some(name) = profile {
eprintln!("[profile: {name}]");
}
eprintln!("error: {err}");
if let Some(hint) = err.hint() {
eprintln!("hint: {hint}");
}
}
}
fn render_human(out: &ActionOutput, profile: Option<&str>) {
if let Some(name) = profile {
println!("[profile: {name}]");
}
match out {
ActionOutput::Version(result) => {
println!("ign {} (ignition-cli)", result.cli_version);
if let Some(gateway) = &result.gateway {
let edition = gateway.edition.as_deref().unwrap_or("unknown edition");
println!("gateway {} ({edition})", gateway.ignition_version);
}
for warning in &result.warnings {
println!("warning: {warning}");
}
}
ActionOutput::Status(result) => render_status_human(result),
ActionOutput::Modules(result) => render_modules_human(result),
ActionOutput::Metrics(result) => render_metrics_human(result),
ActionOutput::Sessions(result) => render_sessions_human(result),
ActionOutput::SessionsTerminate(result) => render_terminate_human(result),
ActionOutput::Connections(result) => render_connections_human(result),
ActionOutput::LogsList(result) => render_logs_list_human(result),
ActionOutput::LogsTail(result) => render_tail_human(result),
ActionOutput::LogsDownload(result) => render_download_human(result),
ActionOutput::LoggersList(result) => render_loggers_human(result),
ActionOutput::LoggerSet(result) => render_set_level_human(result),
ActionOutput::LoggerReset(result) => render_reset_human(result),
ActionOutput::Restart(result) => render_restart_human(result),
ActionOutput::RestartWait(result) => render_restart_wait_human(result),
ActionOutput::Wait(result) => render_wait_human(result),
ActionOutput::Doctor(result) => render_doctor_human(result),
ActionOutput::Adopt(result) => render_adopt_human(result),
ActionOutput::SessionLogin(result) => render_session_login_human(result),
ActionOutput::E2eDoctor(result) => render_e2e_doctor_human(result),
ActionOutput::E2eInit(result) => render_e2e_init_human(result),
ActionOutput::Completions { shell } => {
print!("{}", crate::completions::completions(*shell));
}
ActionOutput::ProfileAdd(result) => {
println!("added profile {} ({})", result.name, result.url);
if result.active {
println!("active profile set to {}", result.name);
}
}
ActionOutput::ProfileList(result) => {
for summary in &result.profiles {
let label = summary.label.as_deref().unwrap_or(&summary.name);
println!(
"{} {} {} {}",
summary.name, label, summary.url, summary.auth_kind
);
}
}
ActionOutput::ProfileUse(result) => println!("active profile set to {}", result.active),
ActionOutput::ProjectsList(result) => render_projects_list_human(result),
ActionOutput::ProjectNew(result) => render_project_new_human(result),
ActionOutput::ProjectCopy(result) => render_project_copy_human(result),
ActionOutput::ProjectRename(result) => render_project_rename_human(result),
ActionOutput::ProjectSet(result) => render_project_set_human(result),
ActionOutput::ProjectDelete(result) => render_project_delete_human(result),
ActionOutput::ProjectExport(result) => render_project_export_human(result),
ActionOutput::ProjectExportDecoded(result) => render_project_export_decoded_human(result),
ActionOutput::ProjectImport(result) => render_project_import_human(result),
ActionOutput::ProjectDiff(result) => render_project_diff_human(result),
ActionOutput::ProjectSync(result) => render_project_sync_human(result),
ActionOutput::ResourcesList(result) => render_resources_list_human(result),
ActionOutput::ResourceGet(result) => render_resource_get_human(result),
ActionOutput::ResourcePut(result) => render_resource_put_human(result),
ActionOutput::ResourceDelete(result) => render_resource_delete_human(result),
ActionOutput::RigUp(result) => render_rig_up_human(result),
ActionOutput::RigDown(result) => render_rig_down_human(result),
ActionOutput::RigReset(result) => render_rig_reset_human(result),
ActionOutput::RigStatus(result) => render_rig_status_human(result),
ActionOutput::RigLogs(result) => render_rig_logs_human(result),
ActionOutput::RigSnapshot(result) => render_rig_snapshot_human(result),
ActionOutput::RigRestore(result) => render_rig_restore_human(result),
ActionOutput::RigModuleUninstall(result) => render_rig_module_uninstall_human(result),
ActionOutput::BackupDownload(result) => render_backup_download_human(result),
ActionOutput::BackupRestore(result) => render_backup_restore_human(result),
ActionOutput::EamHistory(result) => render_eam_history_human(result),
ActionOutput::EamTasks(result) => render_eam_tasks_human(result),
ActionOutput::EamTaskDetail(result) => render_eam_task_detail_human(result),
ActionOutput::EamTaskCreate(result) => render_eam_task_create_human(result),
ActionOutput::EamTaskForce(result) => render_eam_task_force_human(result),
ActionOutput::EamTaskLifecycle(result) => render_eam_task_lifecycle_human(result),
ActionOutput::EamTaskModify(result) => render_eam_task_modify_human(result),
ActionOutput::EamTaskDelete(result) => render_eam_task_delete_human(result),
ActionOutput::ScriptRun(result) => render_script_run_human(result),
ActionOutput::TestingRun(result) => render_testing_run_human(result),
ActionOutput::Lint(result) => render_lint_human(result),
ActionOutput::ApiCall(result) => render_api_call_human(result),
ActionOutput::LicenseStatus(result) => render_license_status_human(result),
ActionOutput::RedundancyStatus(result) => render_redundancy_status_human(result),
ActionOutput::GanStatus(result) => render_gan_status_human(result),
ActionOutput::BundleGenerate(result) => {
render_bundle_status_line("generation started", result)
}
ActionOutput::BundleStatus(result) => render_bundle_status_line("status", result),
ActionOutput::BundleWait(result) => render_bundle_status_line("wait complete", result),
ActionOutput::BundleDownload(result) => render_bundle_download_human(result),
ActionOutput::RigTrialStatus(result) => render_trial_status_human(result),
ActionOutput::RigTrialReset(result) => render_trial_reset_human(result),
ActionOutput::WebdevDeploy(result) => render_webdev_deploy_human(result),
ActionOutput::WebdevStatus(result) => render_webdev_status_human(result),
ActionOutput::TagProviders(result) => render_tag_providers_human(result),
ActionOutput::TagProviderCreate(result) => {
println!("created tag provider {}", result.name);
}
ActionOutput::TagProviderDelete(result) => {
println!("deleted tag provider {}", result.deleted);
}
ActionOutput::TagsBrowse(result) => render_tags_browse_human(result),
ActionOutput::TagsBrowseFromExport(result) => render_tags_browse_from_export_human(result),
ActionOutput::TagsRead(result) => render_tags_read_human(result),
ActionOutput::TagsWrite(result) => {
println!("wrote {} quality: {}", result.path, result.quality);
}
ActionOutput::TagsConfigGet(result) => render_tags_config_get_human(result),
ActionOutput::TagsConfigCreate(result) | ActionOutput::TagsConfigEdit(result) => {
println!(
"{} {} quality: {}",
result.operation, result.path, result.quality
);
}
ActionOutput::TagsConfigDelete(result) => {
println!("deleted {} tag config(s)", result.deleted);
}
ActionOutput::TagsUdtTypes(result) => render_tags_udt_types_human(result),
ActionOutput::TagsUdtDef(result) => render_tags_udt_def_human(result),
ActionOutput::TagsExport(result) => render_tags_export_human(result),
ActionOutput::TagsImport(result) => {
println!(
"imported {} tag(s) into {} ({})",
result.imported, result.provider, result.collision_policy
);
if let Some(report) = &result.loss_report {
for fact in &report.facts {
eprintln!("warning: [{}] {}", fact.code, fact.detail);
}
}
for failure in &result.failed {
eprintln!("warning: gateway reported: {failure}");
}
}
ActionOutput::TagsAlarmsActive(result) => render_tags_alarms_active_human(result),
ActionOutput::TagsAlarmsHistory(result) => render_tags_alarms_history_human(result),
ActionOutput::TagsAlarmsAck(result) => render_tags_alarms_ack_human(result),
ActionOutput::TagsHistoryQuery(result) => render_tags_history_query_human(result),
ActionOutput::WorkspaceCheckout(result) => render_workspace_checkout_human(result),
ActionOutput::WorkspaceStatus(result) => render_workspace_status_human(result),
ActionOutput::WorkspacePush(result) => render_workspace_push_human(result),
#[cfg(feature = "tui")]
ActionOutput::TuiExited => {}
}
}
fn render_workspace_checkout_human(result: &CheckoutOutcome) {
println!(
"checked out {} members → {}",
result.member_count,
result.target.display()
);
if result.scripts_decoded {
println!(
"scripts decoded — *.py sidecars + scripts-manifest.json; an unedited \
re-encode is byte-exact"
);
}
}
fn workspace_state_label(kind: &StatusKind) -> &'static str {
match kind {
StatusKind::Clean => "clean",
StatusKind::LocalEdit => "local_edit",
StatusKind::GatewayDrift => "gateway_drift",
StatusKind::Conflict => "conflict",
StatusKind::Added { local: false } => "added",
StatusKind::Added { local: true } => "added (local)",
StatusKind::Deleted { local: true } => "deleted (local)",
StatusKind::Deleted { local: false } => "deleted (gateway)",
StatusKind::Untracked => "untracked",
}
}
fn render_workspace_status_human(result: &WorkspaceStatus) {
for row in &result.rows {
println!("{} {}", row.path, workspace_state_label(&row.kind));
}
let local_edits = result
.rows
.iter()
.filter(|row| matches!(row.kind, StatusKind::LocalEdit))
.count();
let drift = result
.rows
.iter()
.filter(|row| matches!(row.kind, StatusKind::GatewayDrift))
.count();
let conflicts = result
.rows
.iter()
.filter(|row| matches!(row.kind, StatusKind::Conflict))
.count();
let untracked = result
.rows
.iter()
.filter(|row| matches!(row.kind, StatusKind::Untracked))
.count();
println!(
"{local_edits} local edits, {drift} gateway drift, {conflicts} conflicts, \
{untracked} untracked"
);
}
fn render_workspace_push_human(result: &PushOutcome) {
println!(
"pushed {} — wrote {}, deleted {}, skipped {}",
result.project,
result.wrote.len(),
result.deleted.len(),
result.skipped.len()
);
for path in &result.wrote {
println!(" wrote: {path}");
}
for path in &result.deleted {
println!(" deleted: {path}");
}
for path in &result.skipped {
println!(" skipped: {path}");
}
}
fn render_status_human(result: &StatusResult) {
let gateway = &result.gateway;
let name = gateway.name.as_deref().unwrap_or("gateway");
let edition = gateway.edition.as_deref().unwrap_or("unknown edition");
println!("{} {} {}", name, gateway.ignition_version, edition);
println!("state: {}", result.state);
let overview = &result.overview;
let java = overview
.java
.as_ref()
.map(|java| format!("Java {} ({})", java.version, java.vendor));
let os = overview
.os
.as_ref()
.map(|os| format!("on {} ({})", os.name, os.arch));
match (java, os) {
(Some(java), Some(os)) => println!("platform: {java} {os}"),
(Some(java), None) => println!("platform: {java}"),
(None, Some(os)) => println!("platform: {os}"),
(None, None) => {}
}
println!("uptime: {}", humanize_duration_ms(overview.uptime_ms));
let cpu = overview.cpu_fraction * 100.0;
let memory = match overview.memory.as_slice() {
[used, max] => format!("{}/{}", human_bytes(*used), human_bytes(*max)),
_ => "-".to_string(),
};
let disk = overview
.disk
.as_ref()
.map(|disk| format!("{}/{}", human_bytes(disk.used), human_bytes(disk.total)));
match disk {
Some(disk) => println!("cpu {cpu:.1}% memory {memory} disk {disk}"),
None => println!("cpu {cpu:.1}% memory {memory}"),
}
match &overview.license {
Some(license) => match license.trial_remaining_s {
Some(remaining_s) => println!(
"license: {}, {} remaining",
license.state,
humanize_duration_ms(remaining_s * 1000)
),
None => println!("license: {}", license.state),
},
None => {
if let Some(license) = &gateway.license {
println!("license: {}", license.mode);
}
}
}
}
fn render_modules_human(result: &ModulesResult) {
for module in &result.items {
let state = module.state.as_deref().unwrap_or("-");
let license = module.license_state.as_deref().unwrap_or("-");
println!(
"{} {} {} {} {}",
module.id, module.name, module.version, state, license
);
}
if result.items.is_empty() {
let kind = if result.quarantined {
"quarantined"
} else {
"healthy"
};
println!("(no {kind} modules)");
}
}
fn render_metrics_human(result: &MetricsResult) {
let gauges = &result.current;
println!(
"cpu {:.1}% heap {}/{}",
gauges.cpu,
human_bytes(gauges.heap_memory as i64),
human_bytes(gauges.max_memory as i64)
);
let threads = &result.threads;
println!(
"threads: {} running, {} waiting, {} timed-waiting, {} blocked",
threads.running, threads.waiting, threads.timed_waiting, threads.blocked
);
if let Some(charts) = &result.history {
for (label, series, is_percent) in [
("cpu", &charts.cpu_datapoints, true),
("heap", &charts.heap_memory_datapoints, false),
("non-heap", &charts.non_heap_memory_datapoints, false),
] {
if let Some(first) = series.first()
&& let Some(last) = series.last()
{
let fmt = |datapoint: &ignition_core::client::metrics::Datapoint| {
if is_percent {
format!("{:.1}% @ {}", datapoint.value, datapoint.timestamp)
} else {
format!(
"{} @ {}",
human_bytes(datapoint.value as i64),
datapoint.timestamp
)
}
};
println!("history {label}: first {}, last {}", fmt(first), fmt(last));
}
}
}
}
fn render_sessions_human(result: &SessionsResult) {
println!("designers ({})", result.designers.len());
for designer in &result.designers {
println!(
"{} {} {} {} {}",
designer.id, designer.user, designer.project, designer.address, designer.lastcomm
);
}
println!("perspective ({})", result.perspective.len());
for session in &result.perspective {
println!(
"{} {} {} {} {}",
session.id,
session.username,
session.project,
session.client_address,
session.last_comm
);
}
println!("vision ({})", result.vision.len());
for client in &result.vision {
println!(
"{} {} {} {} {}",
client.id, client.user, client.project, client.address, client.lastcomm
);
}
}
fn render_terminate_human(result: &TerminateResult) {
println!("terminated {} session {}", result.kind, result.id);
}
fn render_connections_human(result: &ConnectionsResult) {
println!("database ({})", result.database.len());
for connection in &result.database {
println!(
"{} {} {}",
connection.name,
connection.enabled,
serde_json::to_string(&connection.healthchecks).unwrap_or_default()
);
}
println!("opc ({})", result.opc.len());
for connection in &result.opc {
println!(
"{} {} {}",
connection.name,
connection.enabled,
serde_json::to_string(&connection.healthchecks).unwrap_or_default()
);
}
}
pub fn render_log_entry_line(entry: &LogEntry) -> String {
format!(
"{} {:>5} {} {}",
iso_utc(entry.timestamp),
entry.level,
entry.logger_name,
entry.message
)
}
fn render_logs_list_human(result: &LogPage) {
for entry in &result.items {
println!("{}", render_log_entry_line(entry));
}
if result.items.is_empty() {
println!("(no matching log entries)");
}
}
fn render_tail_human(result: &TailResult) {
println!("({} entries streamed)", result.streamed);
}
fn render_download_human(result: &DownloadResult) {
println!("wrote {} ({} bytes)", result.file, result.bytes);
}
fn render_loggers_human(result: &ListEnvelope<ignition_core::client::logs::LoggerInfo>) {
for logger in &result.items {
let level = logger.level.as_deref().unwrap_or("-");
let context = if logger.context.is_null() {
"-".to_string()
} else {
serde_json::to_string(&logger.context).unwrap_or_default()
};
println!("{} {} {}", logger.name, level, context);
}
if result.items.is_empty() {
println!("(no matching loggers)");
}
}
fn render_set_level_human(result: &SetLevelResult) {
println!("set logger {} to {}", result.logger, result.level);
}
fn render_reset_human(result: &ResetResult) {
if result.reset {
println!("reset all logger levels to defaults");
}
}
fn render_restart_human(_result: &ignition_core::actions::restart::RestartResult) {
println!("restarting; gateway READY in ~1 min; consider `ign restart --wait`");
}
fn render_restart_wait_human(result: &ignition_core::actions::restart::RestartWaitResult) {
println!("gateway {} after {}s", result.state, result.elapsed_secs);
}
fn render_wait_human(result: &ignition_core::actions::restart::WaitResult) {
println!(
"{} {} after {}s",
result.target, result.state, result.elapsed_secs
);
}
fn render_doctor_human(result: &ignition_core::actions::doctor::DoctorResult) {
use ignition_core::actions::doctor::CheckStatus;
let mut ok = 0;
let mut warn = 0;
let mut fail = 0;
let mut skip = 0;
for check in &result.checks {
match check.status {
CheckStatus::Ok => ok += 1,
CheckStatus::Warn => warn += 1,
CheckStatus::Fail => fail += 1,
CheckStatus::Skip => skip += 1,
}
let status = match check.status {
CheckStatus::Ok => "OK",
CheckStatus::Warn => "WARN",
CheckStatus::Fail => "FAIL",
CheckStatus::Skip => "SKIP",
};
println!("{:<12} {:<4} {}", check.name, status, check.detail);
if check.status == CheckStatus::Fail
&& let Some(hint) = &check.hint
{
println!(" hint: {hint}");
}
}
let mut summary = format!(
"{} checks: {ok} ok, {warn} warn, {fail} fail",
result.checks.len()
);
if skip > 0 {
summary.push_str(&format!(", {skip} skip"));
}
println!("{summary}");
}
fn render_adopt_human(result: &ignition_core::actions::adopt::AdoptResult) {
use ignition_core::actions::doctor::CheckStatus;
for step in &result.steps {
let status = match step.status {
CheckStatus::Ok => "OK",
CheckStatus::Warn => "WARN",
CheckStatus::Fail => "FAIL",
CheckStatus::Skip => "SKIP",
};
println!("{:<12} {:<4} {}", step.name, status, step.detail);
if let Some(hint) = &step.hint {
println!(" hint: {hint}");
}
}
match (&result.stored, &result.token) {
(Some(how), _) => println!("credential: {how}"),
(None, _) => println!("credential: unchanged (key pre-existed)"),
}
if let Some(token) = &result.token {
println!();
println!("API token (shown ONCE — store it now):");
println!(" {token}");
}
}
fn render_e2e_doctor_human(result: &ignition_core::actions::e2e::E2eDoctorResult) {
use ignition_core::actions::doctor::CheckStatus;
println!("scaffold {}", result.dir);
for check in &result.checks {
let status = match check.status {
CheckStatus::Ok => "OK",
CheckStatus::Warn => "WARN",
CheckStatus::Fail => "FAIL",
CheckStatus::Skip => "SKIP",
};
println!("{:<14} {:<4} {}", check.name, status, check.detail);
if check.status != CheckStatus::Ok
&& let Some(hint) = &check.hint
{
println!(" hint: {hint}");
}
}
}
fn render_e2e_init_human(result: &ignition_core::actions::e2e::E2eInitResult) {
use ignition_core::actions::e2e::E2eFileStatus;
println!("scaffold {}", result.dir);
for file in &result.files {
let status = match file.status {
E2eFileStatus::Written => "written",
E2eFileStatus::Skipped => "skipped",
};
println!("{status:<10} {}", file.path);
}
let leg = |name: &str, run: &Option<ignition_core::actions::e2e::E2eCommandRun>| {
if let Some(run) = run {
if run.ran {
match run.exit_code {
Some(0) => println!("{name:<10} ok"),
Some(code) => {
println!("{name:<10} exit {code} (the scaffold is still on disk)")
}
None => println!("{name:<10} killed by a signal"),
}
} else {
println!("{name:<10} not run");
}
}
};
leg("npm", &result.npm_install);
leg("browsers", &result.browsers);
println!();
println!("next: cd {} && npm test", result.dir);
}
pub(crate) fn session_login_human_lines(
result: &ignition_core::actions::login::SessionLoginResult,
) -> Vec<String> {
vec![
format!("gateway {}", result.gateway_url),
format!("cookie {}", result.cookie_name),
"session established (cookie value + CSRF token withheld)".to_string(),
"".to_string(),
"The session material — cookie value, CSRF token, and the Playwright".to_string(),
"storageState document — is emitted only under --json:".to_string(),
" ign session login --json".to_string(),
]
}
fn render_session_login_human(result: &ignition_core::actions::login::SessionLoginResult) {
for line in session_login_human_lines(result) {
println!("{line}");
}
}
fn render_projects_list_human(result: &ProjectsResult) {
for project in &result.projects {
let title = project.title.as_deref().unwrap_or("-");
let parent = project.parent.as_deref().unwrap_or("-");
let inheritable = match project.inheritable {
Some(value) => value.to_string(),
None => "-".to_string(),
};
println!(
"{} {} {} {} {}",
project.name, title, project.enabled, parent, inheritable
);
}
if result.projects.is_empty() {
println!("(no projects)");
}
}
fn render_project_new_human(result: &ignition_core::actions::projects::ProjectSummary) {
match &result.parent {
Some(parent) => println!("created {} (parent {})", result.name, parent),
None => println!("created {}", result.name),
}
}
fn render_project_copy_human(result: &ProjectCopyResult) {
println!("copied {} \u{2192} {}", result.from, result.project.name);
}
fn render_project_rename_human(result: &ProjectRenameResult) {
println!(
"renamed {} \u{2192} {}",
result.previous_name, result.project.name
);
}
fn render_project_set_human(result: &ProjectSetResult) {
println!(
"set {} on {}",
result.fields.join(", "),
result.project.name
);
}
fn render_project_delete_human(result: &ProjectDeleteResult) {
println!("deleted {}", result.deleted);
}
fn render_project_export_human(result: &ExportResult) {
println!(
"exported {} \u{2192} {} ({} bytes)",
result.project, result.file, result.bytes
);
println!(
"scope: includes {} \u{b7} excludes {}",
result.scope.includes.join("/"),
result.scope.excludes.join("/")
);
}
fn render_project_export_decoded_human(result: &ExportDecodedResult) {
println!(
"Exported {} members, {} scripts decoded to {}",
result.members, result.scripts_decoded, result.dir
);
println!(
"scope: includes {} \u{b7} excludes {}",
result.scope.includes.join("/"),
result.scope.excludes.join("/")
);
println!(
"edit the <member>.<n>.py sidecars, then re-import with \
`--encode-scripts --file {}`",
result.dir
);
}
fn render_project_import_human(result: &ImportResult) {
println!(
"imported {} ({} bytes, policy {})",
result.name, result.bytes, result.collision_policy
);
}
fn render_project_diff_human(result: &ProjectDiffResult) {
use ignition_core::client::resources::MemberStatus;
println!(
"project {} · {} → {} · statuses are B-relative-to-A (scope {})",
result.project, result.profile_a, result.profile_b, result.scope
);
if result.project_meta.is_empty() {
println!("project.json: no title/enabled/parent differences");
} else {
for delta in &result.project_meta {
println!("project.json {}: {} → {}", delta.field, delta.a, delta.b);
}
}
for (word, status) in [
("ADDED", MemberStatus::Added),
("REMOVED", MemberStatus::Removed),
("CHANGED", MemberStatus::Changed),
] {
let matching: Vec<&str> = result
.entries
.iter()
.filter(|entry| entry.status == status)
.map(|entry| entry.path.as_str())
.collect();
println!("{word} ({})", matching.len());
for path in matching {
println!(" {path}");
}
}
println!(
"{} same, {} added, {} removed, {} changed",
result.summary.same, result.summary.added, result.summary.removed, result.summary.changed
);
}
fn render_project_sync_human(result: &ProjectSyncResult) {
println!(
"synced {} resource(s) {} → {} · project {} (scope {})",
result.synced.len(),
result.profile_a,
result.profile_b,
result.project,
result.scope
);
for path in &result.synced {
println!(" + {path}");
}
if !result.removed.is_empty() {
println!(
"removed {} resource(s) on {}:",
result.removed.len(),
result.profile_b
);
for path in &result.removed {
println!(" - {path}");
}
}
}
fn render_resources_list_human(result: &ResourcesResult) {
for entry in &result.resources {
println!("{}", entry.path.as_deref().unwrap_or("-"));
}
if result.resources.is_empty() {
println!("(no resources)");
}
}
fn render_resource_get_human(result: &ResourceGetResult) {
if result.content_kind == "json" {
println!(
"{}",
serde_json::to_string_pretty(&result.content).unwrap_or_default()
);
} else {
println!("{}", result.content.as_str().unwrap_or_default());
}
}
fn render_resource_put_human(result: &ResourcePutResult) {
println!("put {} ({})", result.path, result.content_kind);
}
fn render_resource_delete_human(result: &ResourceDeleteResult) {
println!("deleted {}", result.deleted);
}
fn orphan_lines(orphans: &[OrphanedModule]) -> Vec<String> {
orphans
.iter()
.map(|orphan| {
format!(
"orphaned module: {} ({}) is no longer declared but is still installed on \
the gateway — remove it with: {}",
orphan.id, orphan.gateway_module_id, orphan.remove_with
)
})
.collect()
}
fn render_orphaned_modules(orphans: &[OrphanedModule]) {
for line in orphan_lines(orphans) {
println!("{line}");
}
}
fn render_rig_up_human(result: &RigUpResult) {
match result.state.as_str() {
"uncommissioned" => {
let url = result.gateway_url.as_deref().unwrap_or("<gateway>");
println!(
"rig {} up — uncommissioned (open {url}/welcome)",
result.rig
);
}
_ => match result.gateway_url.as_deref() {
Some(url) => println!("rig {} up — gateway RUNNING ({url})", result.rig),
None => println!("rig {} up — compose --wait satisfied", result.rig),
},
}
for warning in &result.warnings {
println!("warning: {warning}");
}
render_orphaned_modules(&result.orphaned_modules);
}
fn render_rig_down_human(result: &RigDownResult) {
println!("rig {} down", result.rig);
}
fn render_rig_reset_human(result: &RigResetResult) {
if result.removed_volumes.is_empty() {
println!(
"rig {} reset — no named volumes found to remove",
result.rig
);
} else {
println!("rig {} reset — removed volumes:", result.rig);
for volume in &result.removed_volumes {
println!(" - {volume}");
}
}
match result.state.as_str() {
"uncommissioned" => println!("gateway up — uncommissioned (fresh volume)"),
_ => println!("gateway RUNNING"),
}
for warning in &result.warnings {
println!("warning: {warning}");
}
render_orphaned_modules(&result.orphaned_modules);
}
fn render_rig_logs_human(result: &RigLogsResult) {
println!("({} lines streamed)", result.streamed);
}
fn render_rig_snapshot_human(result: &SnapshotResult) {
println!(
"snapshot {} — gwbk {} ({} bytes)",
result.dir,
human_bytes(result.gwbk_bytes as i64),
result.gwbk_bytes
);
if result.projects.is_empty() {
println!("projects: (none on the gateway)");
} else {
println!("projects:");
for project in &result.projects {
println!(" - {project}");
}
}
println!("manifest: {}", result.manifest_path);
}
fn render_rig_restore_human(result: &RestoreResult) {
println!(
"restored from {} — gateway {}",
result.restored_from, result.state
);
for warning in &result.warnings {
println!("warning: {warning}");
}
}
fn render_rig_module_uninstall_human(result: &ModuleUninstallResult) {
println!(
"rig {}: uninstalled module {} ({}) from the gateway",
result.rig, result.module_id, result.gateway_module_id
);
}
fn render_backup_download_human(result: &BackupDownloadResult) {
println!("Downloaded {} ({})", result.file, result.r#type);
}
fn render_backup_restore_human(result: &BackupRestoreResult) {
if result.restored {
println!("Restored — the gateway restarts now (blocked for ~minutes)");
}
}
fn render_eam_history_human(result: &EamHistoryResult) {
if result.items.is_empty() {
println!("no EAM task history");
return;
}
for item in &result.items {
let start = iso_utc(item.task_start);
let level = item.level.as_deref().unwrap_or("-");
println!(
"{} {} [{}] target={} {}",
start,
item.task_name,
level,
item.target.as_deref().unwrap_or("-"),
item.detail.as_deref().unwrap_or("")
);
}
println!("({} run(s))", result.count);
}
fn render_eam_tasks_human(result: &EamTasksResult) {
if result.tasks.is_empty() {
println!("no EAM task definitions");
return;
}
for task in &result.tasks {
println!(
"{} type={} schedule={} state={}",
task.name,
task.task_type.as_deref().unwrap_or("-"),
task.schedule_mode.as_deref().unwrap_or("-"),
task.current_state.as_deref().unwrap_or("-"),
);
}
}
fn render_eam_task_create_human(result: &EamTaskCreateResult) {
println!(
"created {} ({} / {})",
result.name, result.task_type, result.schedule_mode
);
println!(
"{}",
serde_json::to_string_pretty(&result.definition).unwrap_or_default()
);
}
fn render_eam_task_force_human(result: &EamTaskForceResult) {
println!(
"dispatched {} (owner {}) — run outcomes:",
result.task, result.owner
);
match &result.history {
Some(item) => {
println!(
" [{}] {}",
item.level.as_deref().unwrap_or("-"),
item.detail.as_deref().unwrap_or("(no detail)")
);
}
None => println!(" (no history entry visible yet)"),
}
}
fn render_eam_task_lifecycle_human(result: &EamLifecycleResult) {
println!(
"{} {} previous-state: {}",
result.action,
result.task,
result.previous_state.as_deref().unwrap_or("-")
);
if let Some(suspended) = result.config_suspended {
println!("config.profile.isSuspended: {suspended}");
}
if let Some(pending) = &result.pending {
println!(
"pending remains: {} canCancel: {}",
if pending.task_state.is_empty() {
"-"
} else {
&pending.task_state
},
pending.can_cancel
);
}
if !result.fired {
println!(
"not fired: {}",
result.reason.as_deref().unwrap_or("(no reason given)")
);
}
}
fn render_eam_task_modify_human(result: &EamModifyResult) {
let changed = if result.changed.is_empty() {
"(none)".to_string()
} else {
result.changed.join(", ")
};
println!("modified {} changed: {changed}", result.task);
let signature = result
.put_outcome
.as_ref()
.and_then(|outcome| outcome.changes.first())
.map(|change| change.new_signature.as_deref().unwrap_or("-"));
println!("new signature: {}", signature.unwrap_or("-"));
}
fn render_eam_task_delete_human(result: &EamDeleteResult) {
if result.deleted {
let affected = if result.affected.is_empty() {
"(none reported)".to_string()
} else {
result.affected.join(", ")
};
println!("deleted {} affected: {affected}", result.task);
} else {
println!("delete did NOT land for {}", result.task);
}
}
fn render_lint_human(result: &LintResult) {
println!(
"lint: {} issue(s), child exit {}",
result.issues_found,
result
.child_exit_code
.map_or("signal".to_string(), |code| code.to_string())
);
if let Some(report) = &result.report
&& let Some(summary) = report.get("summary")
{
let compact = serde_json::to_string(summary).unwrap_or_default();
println!("summary: {compact}");
} else {
println!("(no JSON report parsed — stdout rides the JSON data)");
}
if !result.stderr_preview.is_empty() {
for line in result.stderr_preview.lines() {
println!("{line}");
}
}
}
fn render_script_run_human(result: &ScriptRunResult) {
println!("stdout:");
print!("{}", result.stdout);
if !result.stdout.is_empty() && !result.stdout.ends_with('\n') {
println!();
}
println!("result: {}", result.result);
println!("elapsed: {} ms", result.elapsed_ms);
}
fn render_testing_run_human(result: &TestingRunResult) {
if result.mode == "discover" {
for module in &result.modules {
println!("{module}");
}
println!("{} module(s) discovered", result.count);
return;
}
println!(
"passed: {} failed: {} skipped: {} errors: {} (total {}, {} ms)",
result.passed,
result.failed,
result.skipped,
result.errors,
result.total,
result.duration_ms
);
println!(
"verdict: {}",
result.verdict.as_deref().unwrap_or("unknown")
);
if !result.report.is_empty() {
println!("{}", result.report);
}
}
fn render_api_call_human(result: &ApiCallOutcome) {
println!(
"api call → {} {} (HTTP {})",
result.method, result.path, result.result.status
);
let raw = result.result.data.get();
match serde_json::from_str::<serde_json::Value>(raw) {
Ok(value) => match serde_json::to_string_pretty(&value) {
Ok(pretty) => println!("{pretty}"),
Err(_) => println!("{raw}"),
},
Err(_) => println!("{raw}"),
}
}
fn render_license_status_human(result: &LicenseStatusResult) {
let trial = &result.trial;
let countdown = if trial.expired {
"expired".to_string()
} else {
format!(
"{} remaining",
humanize_duration_ms(trial.trial_seconds_left * 1000)
)
};
println!("license: {}, {}", trial.license_mode, countdown);
let license = &result.license;
if license.hardware.is_empty() {
println!("hardware: (no entries)");
}
for entry in &license.hardware {
let key = entry.key.as_deref().unwrap_or("-");
println!("{key}: {} item(s)", entry.items.len());
for item in &entry.items {
println!(
" {} {} {}",
item.name.as_deref().unwrap_or("-"),
item.title.as_deref().unwrap_or("-"),
item.version.as_deref().unwrap_or("-"),
);
}
}
}
fn render_redundancy_status_human(result: &RedundancyStatusResult) {
let status = &result.status;
println!("role: {}", status.role);
if let Some(state) = &status.project_state {
println!("project state: {state}");
}
if let Some(level) = &status.activity_level {
println!("activity: {level}");
}
match &status.peer_id {
Some(peer) => println!(
"peer: {} ({})",
peer,
if status.peer_connected {
"connected"
} else {
"disconnected"
}
),
None => println!("peer: none"),
}
println!(
"config access: {} sync pending: {} failover pending: {}",
if status.has_config_access {
"yes"
} else {
"no"
},
status.sync_pending,
status.failover_pending
);
if let Some(uptime) = status.uptime {
println!(
"uptime: {} (ms since gateway start)",
humanize_duration_ms(uptime)
);
}
match status.last_sync_epoch_ms() {
Some(ms) => println!("last sync: {ms} epoch-ms (unit inferred, not capture-proven)"),
None => println!("last sync: never (-1 sentinel)"),
}
}
fn render_gan_status_human(result: &GanStatusResult) {
let gan = &result.status;
println!(
"connections: {}/{} running remote gateways: {}",
gan.running_connections, gan.total_connections, gan.remote_gateways
);
println!(
"byte rate in {:.0} B/s out {:.0} B/s",
gan.incoming_byte_rate, gan.outgoing_byte_rate
);
}
fn render_bundle_status_line(verb: &str, wire: &BundleStatusWire) {
match wire.file_size {
Some(bytes) => println!("bundle {verb}: {} ({bytes} bytes)", wire.state),
None => println!("bundle {verb}: {}", wire.state),
}
}
fn render_bundle_download_human(result: &BundleDownloadResult) {
println!("wrote {} ({} bytes)", result.file, result.bytes);
}
fn render_eam_task_detail_human(result: &EamTaskDetailResult) {
println!(
"{} state={}",
result.name,
result
.state
.get("currentState")
.and_then(serde_json::Value::as_str)
.unwrap_or("-")
);
println!(
"{}",
serde_json::to_string_pretty(&result.definition).unwrap_or_default()
);
}
fn render_trial_status_human(result: &TrialStatusResult) {
let banner = if result.expired {
"trial EXPIRED".to_string()
} else {
format!(
"trial active, {} remaining",
humanize_duration_ms(result.trial_remaining_s * 1000)
)
};
println!(
"license: {} ({}), {}",
result.license_mode, result.trial_state, banner
);
let expire = match result.banners.expire_time_ms {
Some(ms) => iso_utc(ms),
None => "-".to_string(),
};
println!(
"banners: severity {}, expires {}, cross-check {}",
result.banners.severity.as_deref().unwrap_or("-"),
expire,
if result.banners.active {
"active"
} else {
"inactive"
}
);
for warning in &result.warnings {
println!("warning: {warning}");
}
}
fn render_trial_reset_human(result: &TrialResetResult) {
println!(
"trial reset via {} — expired {} → {}, {} remaining ({})",
result.mechanism,
result.expired_before,
result.expired_after,
humanize_duration_ms(result.trial_remaining_s * 1000),
result.rig_url
);
}
fn render_webdev_deploy_human(result: &WebdevDeployResult) {
println!(
"deployed {} routes to project {} (overwrite import)",
result.routes.len(),
result.project
);
println!("routes: {}", result.routes.join(", "));
if result.script_exec {
if result.secret_rotated {
println!(
"scriptExec: deployed (secret generated — stored in the profile config at 0600)"
);
} else {
println!("scriptExec: deployed (reusing the stored profile secret)");
}
}
println!(
"import: {}",
serde_json::to_string(&result.import).unwrap_or_else(|_| "{}".to_string())
);
}
fn render_webdev_status_human(result: &WebdevStatusResult) {
for row in &result.routes {
println!(
"{:<12} {:<16} {}",
row.route,
route_status_word(row.status),
row.deployed_version.as_deref().unwrap_or("-")
);
}
if result.ok {
println!("ok: all always-on routes present with matching versions");
} else {
println!("degraded: run `ign webdev deploy` to install/refresh the routes");
}
}
fn route_status_word(status: ignition_core::actions::webdev::RouteStatus) -> &'static str {
use ignition_core::actions::webdev::RouteStatus;
match status {
RouteStatus::Present => "present",
RouteStatus::Absent => "absent",
RouteStatus::Unlicensed => "unlicensed",
RouteStatus::AuthGated => "auth_gated",
RouteStatus::SecretMismatch => "secret_mismatch",
RouteStatus::VersionMismatch => "version_mismatch",
}
}
fn render_tag_providers_human(result: &TagProvidersResult) {
println!("{:<20} {:<8} {:>5} health", "name", "enabled", "tags");
for provider in &result.providers {
let tags = provider
.tag_count
.map(|count| count.to_string())
.unwrap_or_else(|| "-".to_string());
let health = provider.health.as_deref().unwrap_or("-");
let managed = if provider.managed { " (managed)" } else { "" };
println!(
"{:<20} {:<8} {:>5} {}{}",
provider.name, provider.enabled, tags, health, managed
);
}
}
fn browse_depth(path: &str) -> usize {
match path.find(']') {
Some(close) => {
let rest = &path[close + 1..];
if rest.is_empty() {
0
} else {
1 + rest.matches('/').count()
}
}
None => path.matches('/').count(),
}
}
fn render_tags_browse_human(result: &TagsBrowseResult) {
let root = if result.path.is_empty() {
"root".to_string()
} else {
result.path.clone()
};
match &result.filter {
Some(filter) => println!("browsing {root} (filter: {filter})"),
None => println!("browsing {root}"),
}
print_browse_tree(&result.entries);
}
fn render_tags_browse_from_export_human(result: &TagBrowseFromExportResult) {
println!("browsing export {}", result.origin);
print_browse_tree(&result.entries);
}
fn print_browse_tree(entries: &[BrowseRow]) {
for entry in entries {
let depth = browse_depth(&entry.path);
let data_type = entry.data_type.as_deref().unwrap_or_default();
let badge = if data_type.is_empty() {
entry.tag_type.clone()
} else {
format!("{} {}", entry.tag_type, data_type)
};
println!("{}{} {}", " ".repeat(depth), entry.name, badge);
}
}
fn render_tags_read_human(result: &TagsReadResult) {
for row in &result.results {
let value = serde_json::to_string(&row.value).unwrap_or_default();
println!(
"{} = {} [{}] {}",
row.path, value, row.quality, row.timestamp
);
}
}
fn render_tags_config_get_human(result: &TagsConfigGetResult) {
let tag_type = result.tag_type.as_deref().unwrap_or("-");
println!("{} {}", result.path, tag_type);
let pretty = serde_json::to_string_pretty(&result.config).unwrap_or_default();
println!("{pretty}");
if let Some(summary) = history_summary(&result.config) {
println!("history:");
if let Some(enabled) = summary.enabled {
println!(" enabled: {enabled}");
}
if let Some(provider) = &summary.provider {
println!(" provider: {provider}");
}
if let Some(sample_mode) = &summary.sample_mode {
println!(" sample mode: {sample_mode}");
}
if let Some(group) = &summary.historical_group {
println!(" historical group: {group}");
}
}
}
fn render_tags_udt_types_human(result: &TagsUdtTypesResult) {
println!("provider {}", result.provider);
for row in &result.types {
println!("{} {}", row.name, row.tag_type);
}
}
fn render_tags_udt_def_human(result: &TagsUdtDefResult) {
println!("[{}]_types_/{}", result.provider, result.name);
let pretty = serde_json::to_string_pretty(&result.definition).unwrap_or_default();
println!("{pretty}");
}
fn render_tags_export_human(result: &TagsExportResult) {
let file = result.file.as_deref().unwrap_or("stdout");
if result.format == "json" {
println!(
"exported {} path(s) → {} ({} tag(s))",
result.paths.len(),
file,
result.tag_count
);
} else {
println!(
"exported {} path(s) → {} ({}, {} tag(s))",
result.paths.len(),
file,
result.format,
result.tag_count
);
}
if let Some(report) = &result.loss_report {
render_csv_loss_warnings(report);
}
}
fn render_csv_loss_warnings(report: &LossReport) {
if !report.dropped_keys.is_empty() {
eprintln!(
"warning: csv export dropped field(s): {}",
report.dropped_keys.join(", ")
);
}
for line in &report.coerced {
eprintln!("warning: {line}");
}
}
fn cell_text(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text.clone(),
serde_json::Value::Null => "null".to_string(),
other => serde_json::to_string(other).unwrap_or_default(),
}
}
fn render_aligned_columns(columns: &[String], rows: &[Vec<String>]) {
let mut widths: Vec<usize> = columns.iter().map(String::len).collect();
for row in rows {
for (idx, cell) in row.iter().enumerate() {
widths[idx] = widths[idx].max(cell.len());
}
}
let header: Vec<String> = columns
.iter()
.enumerate()
.map(|(idx, name)| {
if idx + 1 == columns.len() {
name.clone()
} else {
format!("{:<width$}", name, width = widths[idx])
}
})
.collect();
println!("{}", header.join(" "));
for row in rows {
let line: Vec<String> = row
.iter()
.enumerate()
.map(|(idx, cell)| {
if idx + 1 == row.len() {
cell.clone()
} else {
format!("{:<width$}", cell, width = widths[idx])
}
})
.collect();
println!("{}", line.join(" "));
}
}
fn render_tags_alarms_active_human(result: &TagsAlarmsActiveResult) {
println!(
"{:<38} {:<44} {:<24} {:<8} name",
"eventId", "source", "state", "priority"
);
for alarm in &result.alarms {
let name = alarm.name.as_deref().unwrap_or("-");
println!(
"{:<38} {:<44} {:<24} {:<8} {}",
alarm.event_id, alarm.source, alarm.state, alarm.priority, name
);
}
}
fn render_tags_alarms_history_human(result: &TagsAlarmsHistoryResult) {
let rows: Vec<Vec<String>> = result
.rows
.iter()
.map(|row| {
result
.columns
.iter()
.map(|column| cell_text(&row[column]))
.collect()
})
.collect();
render_aligned_columns(&result.columns, &rows);
println!("{} row(s)", result.count);
}
fn render_tags_alarms_ack_human(result: &TagsAlarmsAckResult) {
if result.unacknowledged.is_empty() {
println!("acknowledged {} alarm(s)", result.acknowledged);
} else {
println!(
"acknowledged {} alarm(s); unacknowledged: {}",
result.acknowledged,
result.unacknowledged.join(", ")
);
}
}
fn render_tags_history_query_human(result: &TagsHistoryQueryResult) {
let rows: Vec<Vec<String>> = result
.rows
.iter()
.map(|row| row.iter().map(cell_text).collect())
.collect();
render_aligned_columns(&result.columns, &rows);
println!("{} row(s)", result.row_count);
}
fn render_rig_status_human(result: &RigStatusResult) {
println!("rig {} ({})", result.rig, result.compose_file);
for service in &result.services {
let health = service.health.as_deref().unwrap_or("-");
let ports = if service.publishers.is_empty() {
"-".to_string()
} else {
service
.publishers
.iter()
.map(|publisher| {
let protocol = publisher.protocol.as_deref().unwrap_or("tcp");
match (publisher.published_port, publisher.target_port) {
(Some(published), Some(target)) => {
format!("{published}\u{2192}{target}/{protocol}")
}
(Some(published), None) => format!("{published}/{protocol}"),
_ => "-".to_string(),
}
})
.collect::<Vec<_>>()
.join(", ")
};
println!(
"{:<16} {:<8} {:<9} {}",
service.name, service.state, health, ports
);
}
if result.services.is_empty() {
println!("(no running services — rig is down)");
}
let volumes = if result.volumes.is_empty() {
"-".to_string()
} else {
result.volumes.join(", ")
};
println!("volumes: {volumes}");
println!(
"ports {}",
if result.ports_free { "free" } else { "in use" }
);
}
fn iso_utc(ms: i64) -> String {
let secs = ms.div_euclid(1000);
let millis = ms.rem_euclid(1000);
let days = secs.div_euclid(86_400);
let time_of_day = secs.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
let hour = time_of_day / 3600;
let minute = (time_of_day % 3600) / 60;
let second = time_of_day % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z")
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
}
fn humanize_duration_ms(ms: i64) -> String {
let total_s = ms / 1000;
let units = [
(total_s / 86400, "d"),
((total_s % 86400) / 3600, "h"),
((total_s % 3600) / 60, "m"),
(total_s % 60, "s"),
];
let mut parts = units
.iter()
.filter(|(value, _)| *value > 0)
.map(|(value, unit)| format!("{value}{unit}"));
match (parts.next(), parts.next()) {
(Some(first), Some(second)) => format!("{first} {second}"),
(Some(first), None) => first,
(None, _) => "0s".to_string(),
}
}
fn human_bytes(bytes: i64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if value.fract() == 0.0 {
format!("{value:.0}{}", UNITS[unit])
} else {
format!("{value:.1}{}", UNITS[unit])
}
}
#[cfg(test)]
mod tests {
use super::{civil_from_days, iso_utc, orphan_lines};
use ignition_core::rig::OrphanedModule;
fn orphan(id: &str, gateway: &str) -> OrphanedModule {
OrphanedModule {
id: id.to_string(),
gateway_module_id: gateway.to_string(),
remove_with: format!("ign rig module uninstall {id} --yes"),
}
}
#[test]
fn no_orphans_render_no_lines() {
assert!(orphan_lines(&[]).is_empty());
}
#[test]
fn each_orphan_renders_a_line_naming_the_removal_command() {
let lines = orphan_lines(&[
orphan("git", "com.axone_io.ignition.git"),
orphan("project-scan-endpoint", "com.example.scan"),
]);
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("git (com.axone_io.ignition.git)"));
assert!(lines[0].contains("ign rig module uninstall git --yes"));
assert!(lines[1].contains("ign rig module uninstall project-scan-endpoint --yes"));
assert!(!lines[0].contains("scan"), "lines do not bleed together");
}
#[test]
fn iso_utc_known_instants() {
assert_eq!(iso_utc(0), "1970-01-01T00:00:00.000Z");
assert_eq!(iso_utc(1_787_346_747_022), "2026-08-21T21:12:27.022Z");
assert_eq!(iso_utc(1_709_164_800_000), "2024-02-29T00:00:00.000Z");
assert_eq!(iso_utc(-1), "1969-12-31T23:59:59.999Z");
assert_eq!(iso_utc(951_782_400_000), "2000-02-29T00:00:00.000Z");
}
#[test]
fn civil_from_days_matches_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
assert_eq!(civil_from_days(-1), (1969, 12, 31));
}
#[test]
fn session_login_human_lines_withhold_the_session_material() {
use ignition_core::actions::login::{SessionLoginResult, storage_state_for};
use ignition_core::client::idp::GatewaySession;
const SECRET_COOKIE: &str = "SESSION-VALUE-MUST-NOT-APPEAR";
const SECRET_CSRF: &str = "CSRF-TOKEN-MUST-NOT-APPEAR";
let session = GatewaySession {
cookie_name: "webui-sid-1766878194".into(),
cookie_value: SECRET_COOKIE.into(),
csrf_token: SECRET_CSRF.into(),
};
let result = SessionLoginResult {
cookie_name: session.cookie_name.clone(),
cookie_value: session.cookie_value.clone(),
csrf_token: session.csrf_token.clone(),
gateway_url: "http://localhost:9088".into(),
storage_state: storage_state_for(&session, "http://localhost:9088").unwrap(),
};
let rendered = super::session_login_human_lines(&result).join("\n");
assert!(
rendered.contains("webui-sid-1766878194"),
"the cookie NAME is shown: {rendered}"
);
assert!(
rendered.contains("http://localhost:9088"),
"the gateway URL is shown: {rendered}"
);
assert!(
!rendered.contains(SECRET_COOKIE),
"the cookie VALUE leaked into the human render: {rendered}"
);
assert!(
!rendered.contains(SECRET_CSRF),
"the CSRF token leaked into the human render: {rendered}"
);
assert!(
rendered.contains("--json"),
"the render points at where the material lives: {rendered}"
);
}
}