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: {}", escape_error_text(&err.to_string()));
if let Some(hint) = err.hint() {
eprintln!("hint: {}", escape_error_text(&hint));
}
}
}
fn render_human(out: &ActionOutput, profile: Option<&str>) {
if let Some(name) = profile
&& !matches!(
out,
ActionOutput::DeployPlan(_) | ActionOutput::DeployPull(_)
)
{
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::JvmSample(result) => render_jvm_sample_human(result),
ActionOutput::JvmJfrStart(result) => render_jvm_jfr_start_human(result),
ActionOutput::JvmJfrStatus(result) => render_jvm_jfr_status_human(result),
ActionOutput::JvmJfrStop(result) => render_jvm_jfr_stop_human(result),
ActionOutput::TestingRun(result) => render_testing_run_human(result),
ActionOutput::Lint(result) => render_lint_human(result),
ActionOutput::DeployRender(result) => render_deploy_render_human(result),
ActionOutput::DeployPlan(result) => render_deploy_plan_human(result),
ActionOutput::DeployPull(result) => render_deploy_pull_human(result),
ActionOutput::DeployVerify(result) => render_deploy_verify_human(result),
ActionOutput::DeployApply(result) => render_deploy_apply_human(result),
ActionOutput::DeployRollback(result) => render_deploy_rollback_human(result),
ActionOutput::DeployProjects(result) => render_deploy_projects_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_deploy_render_human(result: &ignition_core::actions::deploy::DeployRenderResult) {
println!(
"rendered gateways/{} for env {} (mode {}): {} files, {} bytes",
result.gateway, result.env, result.mode, result.files, result.bytes
);
println!("render_sha256 {}", result.render_sha256);
if let Some(out) = &result.out {
println!("wrote {} files to {}", result.files, out);
}
for warning in &result.warnings {
eprintln!("warning: {warning}");
}
}
fn render_deploy_plan_human(result: &ignition_core::actions::deploy::DeployPlanResult) {
let summary = &result.summary;
println!(
"plan {} ({}, mode {}) via {}: {} added, {} changed, {} removed, {} same, {} unmanaged",
result.gateway,
result.env,
result.mode,
result.transport,
summary.added,
summary.changed,
summary.removed,
summary.same,
summary.unmanaged
);
for entry in &result.entries {
if entry.status == ignition_core::deploy::plan::PlanStatus::Same {
continue;
}
let mut line = format!(
"{} {} core={} live_in={}",
entry.status.word(),
entry.key,
entry.core.word(),
entry.live_in.join(",")
);
if let Some(reason) = &entry.destructive_reason {
line.push_str(&format!(" destructive={reason}"));
}
if entry.lockout_risk {
line.push_str(" lockout_risk");
}
if entry.drift == Some(true) {
line.push_str(" drift");
}
println!("{line}");
if let Some(diff) = &entry.diff {
for text in diff.lines() {
println!(" {text}");
}
}
}
for unit in &result.unreadable {
println!(
"unreadable {} live_in={} declared={} in_ledger={} longest={}",
unit.key,
unit.live_in.join(","),
unit.declared,
unit.in_ledger,
unit.longest
);
}
for path in &result.delete_set.files {
println!("delete {path}");
}
println!("plan_sha256 {}", result.plan_sha256);
eprint_excluded_tag_providers(&result.excluded_tag_providers);
for warning in &result.warnings {
eprintln!("warning: {warning}");
}
}
fn eprint_excluded_tag_providers(excluded: &[String]) {
eprintln!("excluded tag providers: {}", excluded.join(", "));
}
fn render_deploy_pull_human(result: &ignition_core::actions::deploy::DeployPullResult) {
println!(
"pulled {} files into {}/ from nodes.master via {} ({} skipped, manifest {})",
result.files_written,
result.out,
result.transport,
result.skipped.len(),
result.manifest
);
for skipped in &result.skipped {
println!("skipped {}: {}", skipped.path, skipped.reason);
}
println!("render_sha256 {}", result.render_sha256);
eprint_excluded_tag_providers(&result.excluded_tag_providers);
for warning in &result.warnings {
eprintln!("warning: {warning}");
}
}
fn render_deploy_verify_human(result: &ignition_core::actions::deploy::DeployVerifyResult) {
println!(
"verify {} ({}, mode {}): {}",
result.gateway,
result.env,
result.mode,
if result.ok { "PASS" } else { "FAIL" }
);
for check in &result.checks {
println!(
"{}: {} {}",
check.id.word(),
check.status.word(),
check.detail
);
for finding in &check.findings {
println!(
" {} {} {}{}",
finding.code,
finding.subject,
finding.detail,
warn_suffix(finding)
);
}
}
if let Some(backup) = &result.backup {
render_backup_report_human(backup);
}
if let Some(secrets) = &result.secrets {
render_secret_names_human(secrets);
}
eprint_excluded_tag_providers(&result.excluded_tag_providers);
for warning in &result.warnings {
eprintln!("warning: {warning}");
}
}
fn render_secret_names_human(secrets: &ignition_core::deploy::pair::SecretNamesReport) {
let clean = |text: &str| text.chars().filter(|c| !c.is_control()).collect::<String>();
for node in &secrets.nodes {
println!(
"secrets on {}: {} ({} providers)",
node.node,
node.status.word(),
node.providers_checked
);
for missing in &node.missing {
println!(
" missing: {}/{}",
clean(&missing.provider),
clean(&missing.name)
);
}
for provider in &node.unverifiable {
println!(" unverifiable: {}", clean(provider));
}
}
}
fn escape_control(text: &str) -> String {
escape_control_keeping(text, |_| false)
}
fn escape_control_keeping(text: &str, keep: impl Fn(char) -> bool) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if c.is_control() && !keep(c) {
out.extend(c.escape_default());
} else {
out.push(c);
}
}
out
}
fn escape_error_text(text: &str) -> String {
escape_control_keeping(text, |c| c == '\n')
}
fn deploy_projects_human_lines(
result: &ignition_core::actions::deploy_projects::DeployProjectsResult,
) -> Vec<String> {
use ignition_core::deploy::projects::ProjectAction;
let mut lines = vec![format!(
"project {} on {} (env {}, release {})",
escape_control(&result.project),
escape_control(&result.gateway),
escape_control(&result.env),
escape_control(&result.release)
)];
lines.push(format!(
"release: {} (last deployed by this tool: {})",
escape_control(&result.release),
result
.previous_release
.as_deref()
.map_or_else(|| "none".to_string(), escape_control)
));
let change = if result.would_change {
"would change"
} else {
"no change"
};
if result.check {
lines.push(match result.action {
ProjectAction::Create => format!("check: create ({change})"),
ProjectAction::Skip => format!("check: skip ({change})"),
ProjectAction::Import => format!("check: import ({change})"),
ProjectAction::Record => {
"check: record the release only (no project change)".to_string()
}
});
} else {
lines.push(format!(
"outcome: {}{}",
result.outcome.as_str(),
if result.ok { "" } else { " (NOT verified)" }
));
match (&result.run_id, &result.run_dir) {
(Some(id), Some(dir)) => {
lines.push(format!("run {}", escape_control(id)));
lines.push(format!("run directory {}", escape_control(dir)));
}
_ => lines.push("run none (nothing was written)".to_string()),
}
if result.run_id.is_some() {
lines.push(format!(
"release record: {}",
if result.record_written {
"written"
} else {
"not written"
}
));
}
if let Some(slug) = &result.slug {
lines.push(format!(
"verdict: {}: the project read back does not equal the artifact; the release record was not written and nothing was restored",
escape_control(slug)
));
match &result.restore_command {
Some(command) => {
lines.push(format!(
"restore (run from the repo root, for gateway {}): {}",
escape_control(&result.gateway),
escape_control(command)
));
}
None => lines.push(
"restore: none (the project was created, so no pre-image was saved)"
.to_string(),
),
}
}
}
if let Some(reason) = &result.allow_first_change {
lines.push(format!("first change allowed: {}", escape_control(reason)));
}
if result.allow_downgrade {
lines.push("downgrade allowed (--allow-downgrade)".to_string());
}
lines.push(format!("artifact sha256 {}", result.artifact_sha256));
lines.push(match &result.live_sha256 {
Some(live) => format!("live sha256 {live}"),
None => "live sha256 none (the project is absent from the gateway)".to_string(),
});
lines.push(format!(
"members: {} added, {} changed, {} removed, {} same",
result.members.added, result.members.changed, result.members.removed, result.members.same
));
for path in result.removed.iter().take(20) {
lines.push(format!("removed: {}", escape_control(path)));
}
if result.removed_count > 20 {
lines.push(format!(
"... and {} more removed",
result.removed_count - 20
));
}
lines.push(format!(
"excluded: {} path(s) left out of the artifact",
result.excluded_count
));
for path in result.excluded.iter().take(20) {
lines.push(format!(" {}", escape_control(path)));
}
if result.excluded_count > 20 {
lines.push(format!(" ... and {} more", result.excluded_count - 20));
}
lines.push(format!(
"parent: {}",
result
.parent
.as_deref()
.map_or_else(|| "none".to_string(), escape_control)
));
lines.push(format!(
"other projects on the gateway: {}",
result.unmanaged_count
));
if !result.children.is_empty() {
lines.push("children:".to_string());
for child in &result.children {
lines.push(format!(
" {}: {} ({})",
escape_control(&child.name),
child_status_word(child.status),
child
.release
.as_deref()
.map_or_else(|| "no record".to_string(), escape_control)
));
}
}
for warning in &result.warnings {
lines.push(format!("warning: {}", escape_control(warning)));
}
lines
}
fn child_status_word(status: ignition_core::actions::deploy_projects::ChildStatus) -> &'static str {
use ignition_core::actions::deploy_projects::ChildStatus;
match status {
ChildStatus::Ok => "ok",
ChildStatus::Mismatch => "MISMATCH",
ChildStatus::Unverified => "unverified",
}
}
fn render_deploy_projects_human(
result: &ignition_core::actions::deploy_projects::DeployProjectsResult,
) {
for line in deploy_projects_human_lines(result) {
println!("{line}");
}
}
fn render_deploy_apply_human(result: &ignition_core::actions::deploy_apply::DeployApplyResult) {
use ignition_core::deploy::apply::OutcomeKind;
println!(
"apply {} ({}, mode {}) via {}: {}",
result.gateway,
result.env,
result.mode,
result.transport,
result.outcome.as_str()
);
match (&result.run_id, &result.run_dir) {
(Some(id), Some(dir)) => {
println!("run {id}");
println!("run directory {dir}");
}
_ => println!("run none (nothing to apply)"),
}
println!(
"{} written, {} deleted",
result.written.len(),
result.deleted.len()
);
if result.outcome == OutcomeKind::RolledBack {
let cause = result.cause.map_or("unknown", |c| c.as_str());
println!("rolled back: {cause}");
println!("the pre-apply snapshot was restored; the gateway runs what it ran before");
}
if result.outcome != OutcomeKind::Noop {
match (&result.gwbk, result.skip_gwbk) {
(Some(name), _) => println!("backup {name}"),
(None, true) => println!("backup skipped (--skip-gwbk)"),
(None, false) => println!("backup none taken"),
}
for entry in &result.restart_pending_new {
println!("restart pending (new): {entry}");
}
for entry in &result.restart_pending {
if !result.restart_pending_new.contains(entry) {
println!("restart pending: {entry}");
}
}
}
if let Some(backup) = result.backup.as_deref() {
render_backup_report_human(backup);
}
println!("plan_sha256 {}", result.plan_sha256);
eprint_excluded_tag_providers(&result.excluded_tag_providers);
for warning in &result.warnings {
eprintln!("warning: {warning}");
}
}
fn render_backup_report_human(backup: &ignition_core::deploy::pair::BackupReport) {
match (backup.converged, backup.reason) {
(true, _) => println!("backup: converged"),
(false, Some(reason)) => println!("backup: NOT converged ({})", reason.as_str()),
(false, None) => println!("backup: NOT converged"),
}
if backup.budget_s > 0 {
println!(
"backup waited {} s of {} s",
backup.waited_s, backup.budget_s
);
match &backup.resync {
Some(resync) => match &resync.code {
Some(code) => println!("backup resync sent: failed ({code})"),
None => println!("backup resync sent: ok={}", resync.ok),
},
None => println!("backup resync not sent"),
}
}
for check in &backup.checks {
println!(
" {}: {} {}",
backup_check_word(check.id),
check.status.word(),
check.detail
);
for finding in &check.findings {
println!(
" {} {} {}{}",
finding.code,
finding.subject,
finding.detail,
warn_suffix(finding)
);
}
}
}
fn warn_suffix(finding: &ignition_core::deploy::verify::Finding) -> &'static str {
if finding.status == Some(ignition_core::deploy::verify::CheckStatus::Warn) {
" (warn)"
} else {
""
}
}
fn backup_check_word(id: ignition_core::deploy::pair::BackupCheckId) -> &'static str {
use ignition_core::deploy::pair::BackupCheckId;
match id {
BackupCheckId::Redundancy => "redundancy",
BackupCheckId::Mode => "mode",
BackupCheckId::ReadBack => "read_back",
BackupCheckId::Files => "files",
}
}
fn render_deploy_rollback_human(
result: &ignition_core::actions::deploy_apply::DeployRollbackResult,
) {
println!(
"rollback {} ({}, mode {}): {}",
result.gateway,
result.env,
result.mode,
result.outcome.as_str()
);
use ignition_core::deploy::apply::RollbackKind;
match result.outcome {
RollbackKind::Restored => println!("rolled back run {}", result.target_run_id),
RollbackKind::Cleared => println!(
"cleared interrupted run {}: it had changed nothing, so nothing was restored",
result.target_run_id
),
}
println!("run {}", result.run_id);
println!("run directory {}", result.run_dir);
println!(
"{} restored, {} deleted",
result.restored.len(),
result.deleted.len()
);
if !result.discarded_live_changes.is_empty() {
println!(
"discarded {} live change(s) made after the run:",
result.discarded_live_changes.len()
);
for path in &result.discarded_live_changes {
println!(" {path}");
}
}
for entry in &result.restart_pending_new {
println!("restart pending (new): {entry}");
}
for entry in &result.restart_pending {
if !result.restart_pending_new.contains(entry) {
println!("restart pending: {entry}");
}
}
for warning in &result.warnings {
eprintln!("warning: {warning}");
}
}
pub(crate) const APPLY_PROGRESS_PREFIX: &str = "ign deploy apply:";
pub(crate) const ROLLBACK_PROGRESS_PREFIX: &str = "ign deploy rollback:";
pub(crate) const PROJECTS_PROGRESS_PREFIX: &str = "ign deploy projects:";
pub(crate) struct StderrProjectsObserver;
impl ignition_core::actions::deploy_projects::ProjectsObserver for StderrProjectsObserver {
fn event(&self, e: &ignition_core::actions::deploy_projects::ProjectsEvent) {
eprintln!("{PROJECTS_PROGRESS_PREFIX} {}", describe_projects_event(e));
}
}
pub(crate) fn describe_projects_event(
e: &ignition_core::actions::deploy_projects::ProjectsEvent,
) -> String {
use ignition_core::actions::deploy_projects::ProjectsEvent;
use ignition_core::deploy::projects::ProjectAction;
match e {
ProjectsEvent::DesignersPoll { open } => {
format!("waiting for Designer sessions to close: {open} open")
}
ProjectsEvent::DesignersProceed { sessions } => format!(
"proceeding over {} open Designer session(s) (--designers proceed)",
sessions.len()
),
ProjectsEvent::DesignersSkipped => {
"Designer check skipped (the project itself is not changed)".to_string()
}
ProjectsEvent::RunStarted { run_id, action } => format!(
"run {} started: {}",
escape_control(run_id),
match action {
ProjectAction::Create => "create",
ProjectAction::Skip => "skip",
ProjectAction::Import => "import",
ProjectAction::Record => "record the release only",
}
),
ProjectsEvent::PreImageExported { bytes } => {
format!("pre-image saved to the run directory ({bytes} bytes)")
}
ProjectsEvent::ImportSent { overwrite } => {
if *overwrite {
"import sent (the live project is replaced)".to_string()
} else {
"import sent (the project is created)".to_string()
}
}
ProjectsEvent::ImportAnswered => "import answered".to_string(),
ProjectsEvent::Verified { equal } => {
if *equal {
"verify green: the project equals the artifact".to_string()
} else {
"verify NOT green: the project does not equal the artifact".to_string()
}
}
ProjectsEvent::RecordWritten => "release record written".to_string(),
ProjectsEvent::RecordFailed { code } => {
format!("release record not written ({})", escape_control(code))
}
ProjectsEvent::ChildrenSettling { children } => format!(
"checking {} child project(s) against their last releases (waiting for their exports to settle)",
children.len()
),
ProjectsEvent::ChildChecked { name, status } => format!(
"child {}: {}",
escape_control(name),
child_status_word(*status)
),
ProjectsEvent::Finished { outcome } => format!("finished: {}", outcome.as_str()),
}
}
pub(crate) struct StderrApplyObserver {
prefix: &'static str,
files: std::sync::atomic::AtomicUsize,
}
impl Default for StderrApplyObserver {
fn default() -> Self {
Self::with_prefix(APPLY_PROGRESS_PREFIX)
}
}
impl StderrApplyObserver {
pub(crate) fn with_prefix(prefix: &'static str) -> Self {
Self {
prefix,
files: std::sync::atomic::AtomicUsize::new(0),
}
}
}
impl ignition_core::deploy::apply::ApplyObserver for StderrApplyObserver {
fn event(&self, e: &ignition_core::deploy::apply::ApplyEvent) {
use ignition_core::deploy::apply::ApplyEvent;
use std::sync::atomic::Ordering;
if let ApplyEvent::RunStarted {
writes, deletes, ..
} = e
{
self.files.store(writes + deletes, Ordering::Relaxed);
}
for line in describe_apply_event(e, self.files.load(Ordering::Relaxed)) {
eprintln!("{} {line}", self.prefix);
}
}
}
pub(crate) fn describe_apply_event(
e: &ignition_core::deploy::apply::ApplyEvent,
files: usize,
) -> Vec<String> {
use ignition_core::deploy::apply::ApplyEvent;
let staging = || {
format!(
"staging {files} file(s) on the host before the lock is taken: this step prints \
nothing until it ends and can take several minutes for a large tree"
)
};
match e {
ApplyEvent::RunStarted {
run_id,
writes,
deletes,
} => vec![format!(
"run {run_id} started: {writes} write(s), {deletes} delete(s)"
)],
ApplyEvent::PreflightChecks { checks } => vec![format!(
"preflight checks: {}",
checks
.iter()
.map(|(id, status)| format!("{id}={status}"))
.collect::<Vec<_>>()
.join(" ")
)],
ApplyEvent::DesignersPoll { open } => {
vec![format!(
"waiting for Designer sessions to close: {open} open"
)]
}
ApplyEvent::DesignersProceed { sessions } => vec![format!(
"proceeding over {} open Designer session(s) (--designers proceed)",
sessions.len()
)],
ApplyEvent::GwbkDownloaded { bytes } => {
vec![format!("backup taken ({bytes} bytes)"), staging()]
}
ApplyEvent::GwbkSkipped => vec!["backup skipped (--skip-gwbk)".to_string(), staging()],
ApplyEvent::Staged { files } => vec![format!("staged {files} file(s)")],
ApplyEvent::LockAcquired { hold_s } => {
vec![format!("scan-lock taken for {hold_s} s")]
}
ApplyEvent::Committed { moved, deleted } => {
vec![format!("committed: {moved} moved, {deleted} deleted")]
}
ApplyEvent::LockVerdict { held } => vec![if *held {
"scan-lock still held".to_string()
} else {
"scan-lock lapsed before the scan".to_string()
}],
ApplyEvent::ScanDone => vec!["config scan complete".to_string()],
ApplyEvent::VerifyDone { ok } => {
vec![format!("verify {}", if *ok { "green" } else { "red" })]
}
ApplyEvent::VerifyChecks { checks } => vec![format!(
"verify checks: {}",
checks
.iter()
.map(|(id, status, _)| format!("{id}={status}"))
.collect::<Vec<_>>()
.join(" ")
)],
ApplyEvent::Finalized => vec!["run recorded".to_string()],
ApplyEvent::CommitInterrupted { code } => vec![format!(
"the commit failed ({code}) and left this run's journal on the host: recover it \
with `ign deploy rollback --run <run id>`"
)],
ApplyEvent::RestoreStarted { cause } => vec![match cause {
Some(cause) => format!("restoring the pre-apply snapshot ({})", cause.as_str()),
None => "restoring the snapshot of the run".to_string(),
}],
ApplyEvent::LiveChangesDiscarded { count, paths } => vec![format!(
"going ahead over {count} live change(s) made after the run: {}",
paths.join(", ")
)],
ApplyEvent::Restored => vec!["snapshot restored".to_string()],
ApplyEvent::Cleared => {
vec!["interrupted run removed: it had changed nothing".to_string()]
}
ApplyEvent::InterruptedRun {
phase,
action,
hashes,
} => vec![format!(
"interrupted run found (journal phase {phase}, {} touched path(s)): recovery will {action}",
hashes.len()
)],
ApplyEvent::BackupSampled { last_sync } => vec![match last_sync {
Some(at) => format!("backup node sampled (lastSyncTimestamp {at})"),
None => "backup node sampled (never synced)".to_string(),
}],
ApplyEvent::BackupWaitStarted { budget_s } => {
vec![format!(
"waiting up to {budget_s} s for the backup node to converge"
)]
}
ApplyEvent::BackupPoll {
peer_connected,
sync_pending,
advanced,
} => vec![format!(
"backup node: peer_connected={peer_connected} sync_pending={sync_pending} \
advanced={advanced}"
)],
ApplyEvent::BackupResync { ok, code } => vec![match code {
Some(code) => {
format!("backup not converged: sent one resync to the master (failed: {code})")
}
None => format!("backup not converged: sent one resync to the master (ok={ok})"),
}],
ApplyEvent::BackupVerdict { converged, reason } => vec![match reason {
Some(reason) => format!("backup node did not converge ({reason})"),
None => format!("backup node converged={converged}"),
}],
ApplyEvent::Failed { code } => vec![format!("failed: {code}")],
}
}
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);
}
pub(crate) fn render_jvm_banner() {
eprintln!("================================================================================");
eprintln!("!! JVM PROFILING — ACTIVE GATEWAY INSTRUMENTATION — NOT A ROUTINE DIAGNOSTIC !!");
eprintln!("================================================================================");
eprintln!(
"warning: {}",
ignition_core::actions::jvm::PRODUCTION_WARNING
);
}
fn render_jvm_sample_human(result: &ignition_core::actions::jvm::JvmSampleResult) {
render_jvm_banner();
render_jvm_sample_window(result);
}
pub(crate) fn render_jvm_sample_window(result: &ignition_core::actions::jvm::JvmSampleResult) {
println!(
"sampled {} times over a {}s window (wall {} ms — the {}ms interval rides on \
dump cost; {} thread stacks observed, peak {} live)",
result.samples,
result.duration_secs,
result.wall_ms,
result.interval_ms,
result.observations,
result.thread_peak
);
println!();
println!("hot methods (self):");
if result.hot_methods.is_empty() {
println!(" (no stacks captured)");
}
for method in &result.hot_methods {
println!(
" {:>5.1}% {:>6} {}",
method.pct, method.count, method.frame
);
}
println!();
println!("heaviest stacks:");
for stack in result.top_stacks.iter().take(10) {
println!(" {:>5.1}% {:>6} {}", stack.pct, stack.count, stack.stack);
}
if result.top_stacks.len() > 10 {
println!(
" … {} more stacks in the JSON output / folded sidecar",
result.top_stacks.len() - 10
);
}
}
fn render_jvm_jfr_start_human(result: &ignition_core::actions::jvm::JvmJfrStartResult) {
render_jvm_banner();
println!(
"started flight recording '{}' ({}s, settings={}) — memory-buffered, auto-stops at the deadline",
result.name, result.duration_secs, result.settings
);
if !result.detail.is_empty() {
println!("jfr: {}", result.detail.trim());
}
println!("fetch + clean up with: ign jvm jfr stop (check: ign jvm jfr status)");
}
fn render_jvm_jfr_status_human(result: &ignition_core::actions::jvm::JvmJfrStatusResult) {
render_jvm_banner();
println!(
"ign-cli recording: {}",
if result.active { "ACTIVE" } else { "none" }
);
println!("recordings (jfrCheck verbatim):");
for line in result.recordings.lines() {
println!(" {line}");
}
}
fn render_jvm_jfr_stop_human(result: &ignition_core::actions::jvm::JvmJfrStopResult) {
render_jvm_banner();
println!("stopped flight recording '{}'", result.name);
if result.fetched {
println!(
"wrote {} ({} bytes)",
result.file.as_deref().unwrap_or_default(),
result.bytes
);
println!(
"gateway-side {}: {}",
result.remote_file,
if result.remote_deleted {
"deleted"
} else {
"NOT deleted — remove it manually"
}
);
} else {
eprintln!(
"dump is {} bytes — over the 32 MiB fetch cap; LEFT ON THE GATEWAY at {}",
result.bytes, result.remote_file
);
eprintln!("retrieve it manually and delete it — it holds gateway memory contents");
}
}
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"),
None => println!("last sync: never (-1 sentinel)"),
}
if let Some(report) = &result.active_node {
println!(
"peer ({}): role {}, activity {}",
report.peer_profile,
report.peer_role,
report.peer.activity_level.as_deref().unwrap_or("unknown")
);
match report.active {
"this" => println!("active node: this ({})", report.this_role),
"peer" => println!("active node: peer ({})", report.peer_role),
"both" => println!("active node: both report Active (split-brain)"),
_ => println!("active node: neither reports Active"),
}
}
}
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, deploy_projects_human_lines, describe_apply_event,
describe_projects_event, escape_control, escape_error_text, iso_utc, orphan_lines,
};
use ignition_core::deploy::apply::{ApplyEvent, RollbackCause};
use ignition_core::rig::OrphanedModule;
#[test]
fn apply_events_render_progress_lines() {
let one = |e: &ApplyEvent| -> String {
let lines = describe_apply_event(e, 7);
assert!(!lines.is_empty(), "{e:?} has a line");
lines.join(" | ")
};
assert!(one(&ApplyEvent::GwbkSkipped).contains("backup skipped"));
let downloaded = one(&ApplyEvent::GwbkDownloaded { bytes: 4096 });
assert!(
downloaded.contains("backup taken (4096 bytes)"),
"{downloaded}"
);
assert!(downloaded.contains("staging 7 file(s)"), "{downloaded}");
assert!(
one(&ApplyEvent::PreflightChecks {
checks: vec![("hold_budget".into(), "fail".into())]
})
.contains("hold_budget=fail")
);
assert!(one(&ApplyEvent::DesignersPoll { open: 2 }).contains("2 open"));
assert!(
one(&ApplyEvent::RestoreStarted {
cause: Some(RollbackCause::VerifyFailed)
})
.contains("verify_failed")
);
assert!(one(&ApplyEvent::RestoreStarted { cause: None }).contains("snapshot of the run"));
assert!(one(&ApplyEvent::Cleared).contains("changed nothing"));
assert!(
one(&ApplyEvent::InterruptedRun {
phase: "written",
action: "restore",
hashes: Default::default()
})
.contains("recovery will restore")
);
assert!(
one(&ApplyEvent::CommitInterrupted { code: "x".into() })
.contains("ign deploy rollback --run")
);
}
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}"
);
}
fn projects_result() -> ignition_core::actions::deploy_projects::DeployProjectsResult {
use ignition_core::actions::deploy_projects::{DeployProjectsResult, ProjectOutcome};
use ignition_core::deploy::projects::{MemberDelta, ProjectAction};
DeployProjectsResult {
env: "dev".into(),
gateway: "gw".into(),
project: "App".into(),
release: "v1".into(),
check: true,
action: ProjectAction::Import,
would_change: true,
artifact_sha256: "a".repeat(64),
live_sha256: None,
members: MemberDelta::default(),
removed: Vec::new(),
removed_count: 0,
excluded: Vec::new(),
excluded_count: 0,
unmanaged: Vec::new(),
unmanaged_count: 0,
parent: None,
previous_release: None,
recorded_sha256: None,
live_matches_last_deploy: None,
allow_downgrade: false,
allow_first_change: None,
warnings: Vec::new(),
run_id: None,
run_dir: None,
record_written: false,
restore_command: None,
children: Vec::new(),
outcome: ProjectOutcome::Checked,
ok: true,
slug: None,
}
}
#[test]
fn escape_control_writes_controls_out_and_keeps_the_rest() {
assert_eq!(escape_control("a\u{1b}[31mb"), "a\\u{1b}[31mb");
assert_eq!(escape_control("tab\there\r\n"), "tab\\there\\r\\n");
assert_eq!(escape_control("csi\u{9b}x"), "csi\\u{9b}x");
assert_eq!(
escape_control("caf\u{e9} \u{4e2d}/x.py"),
"caf\u{e9} \u{4e2d}/x.py"
);
}
#[test]
fn error_text_escapes_controls_but_keeps_line_breaks() {
assert_eq!(
escape_error_text("link\u{1b}[2J is a symlink\nsecond line\r"),
"link\\u{1b}[2J is a symlink\nsecond line\\r"
);
}
#[test]
fn excluded_names_with_control_characters_print_escaped() {
let mut result = projects_result();
result.excluded = vec!["tags/\u{1b}[2Jevil\u{7}.json".to_string(), "images/".into()];
result.excluded_count = 2;
let lines = deploy_projects_human_lines(&result);
let text = lines.join("\n");
assert!(
text.contains(" tags/\\u{1b}[2Jevil\\u{7}.json"),
"{text:?}"
);
assert!(text.contains(" images/"), "{text:?}");
assert!(
!text.chars().any(|c| c.is_control() && c != '\n'),
"no raw control character reaches the terminal: {text:?}"
);
}
#[test]
fn removed_names_and_project_names_print_escaped() {
let mut result = projects_result();
result.project = "App\u{1b}[31m".into();
result.env = "d\u{1b}ev".into();
result.gateway = "g\rw".into();
result.removed = vec!["ignition/\u{1b}]0;title\u{7}".to_string()];
result.removed_count = 1;
result.warnings = vec!["w\u{1b}x".to_string()];
let text = deploy_projects_human_lines(&result).join("\n");
assert!(
text.contains("project App\\u{1b}[31m on g\\rw (env d\\u{1b}ev, release v1)"),
"{text:?}"
);
assert!(
text.contains("removed: ignition/\\u{1b}]0;title\\u{7}"),
"{text:?}"
);
assert!(text.contains("warning: w\\u{1b}x"), "{text:?}");
assert!(
!text.chars().any(|c| c.is_control() && c != '\n'),
"{text:?}"
);
}
#[test]
fn parent_line_names_the_parent_or_none_and_prints_escaped() {
let mut result = projects_result();
let text = deploy_projects_human_lines(&result).join("\n");
assert!(text.contains("\nparent: none\n"), "{text:?}");
result.parent = Some("WHK\u{1b}[2J-Global".to_string());
let text = deploy_projects_human_lines(&result).join("\n");
assert!(
text.contains("\nparent: WHK\\u{1b}[2J-Global\n"),
"{text:?}"
);
assert!(
!text.chars().any(|c| c.is_control() && c != '\n'),
"{text:?}"
);
}
#[test]
fn release_line_names_the_previous_release_or_none() {
let mut result = projects_result();
let text = deploy_projects_human_lines(&result).join("\n");
assert!(
text.contains("release: v1 (last deployed by this tool: none)"),
"{text:?}"
);
result.previous_release = Some("v0.9.0".into());
let text = deploy_projects_human_lines(&result).join("\n");
assert!(
text.contains("release: v1 (last deployed by this tool: v0.9.0)"),
"{text:?}"
);
}
#[test]
fn record_action_says_only_the_record_is_written() {
use ignition_core::deploy::projects::ProjectAction;
let mut result = projects_result();
result.action = ProjectAction::Record;
let text = deploy_projects_human_lines(&result).join("\n");
assert!(
text.contains("check: record the release only (no project change)"),
"{text:?}"
);
}
#[test]
fn unverified_verdict_lines_print_the_restore_command_escaped() {
use ignition_core::actions::deploy_projects::ProjectOutcome;
let mut result = projects_result();
result.check = false;
result.outcome = ProjectOutcome::ImportedUnverified;
result.ok = false;
result.slug = Some("deploy_project_unverified".into());
result.run_id = Some("r1".into());
result.run_dir = Some(".ign/deploy/g\u{1b}w/r1".into());
result.restore_command = Some(
"ign project import 'A\u{1b}[2J' --file .ign/deploy/g\u{1b}w/r1/export/x.zip --collision-policy overwrite --yes"
.into(),
);
let text = deploy_projects_human_lines(&result).join("\n");
assert!(
text.contains("verdict: deploy_project_unverified"),
"{text:?}"
);
assert!(
text.contains("A\\u{1b}[2J' --file .ign/deploy/g\\u{1b}w/r1/export/x.zip"),
"{text:?}"
);
assert!(
!text.chars().any(|c| c.is_control() && c != '\n'),
"{text:?}"
);
result.restore_command = None;
let text = deploy_projects_human_lines(&result).join("\n");
assert!(text.contains("restore: none"), "{text:?}");
}
#[test]
fn project_progress_lines_carry_ids_and_flags_only() {
use ignition_core::actions::deploy_projects::{ProjectOutcome, ProjectsEvent};
use ignition_core::deploy::projects::ProjectAction;
let line = |e| describe_projects_event(&e);
assert_eq!(
line(ProjectsEvent::RunStarted {
run_id: "r\u{1b}1".into(),
action: ProjectAction::Import
}),
"run r\\u{1b}1 started: import"
);
assert!(line(ProjectsEvent::PreImageExported { bytes: 7 }).contains("(7 bytes)"));
assert!(line(ProjectsEvent::Verified { equal: false }).contains("NOT green"));
assert!(line(ProjectsEvent::Verified { equal: true }).contains("green"));
assert_eq!(
line(ProjectsEvent::Finished {
outcome: ProjectOutcome::ImportedUnverified
}),
"finished: imported_unverified"
);
}
#[test]
fn first_change_reason_and_warnings_print_escaped() {
let mut result = projects_result();
result.allow_first_change = Some("adopt\u{1b}[2J".to_string());
result.allow_downgrade = true;
result.warnings = vec!["project App: the live project no longer matches".to_string()];
let text = deploy_projects_human_lines(&result).join("\n");
assert!(
text.contains("first change allowed: adopt\\u{1b}[2J"),
"{text:?}"
);
assert!(text.contains("downgrade allowed"), "{text:?}");
assert!(
text.contains("warning: project App: the live project no longer matches"),
"{text:?}"
);
assert!(
!text.chars().any(|c| c.is_control() && c != '\n'),
"{text:?}"
);
}
}