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::actions::deploy_projects::ProjectOutcome;
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"
}
));
}
let rolled_back = matches!(
result.outcome,
ProjectOutcome::TagsRolledBack | ProjectOutcome::RolledBack
);
if let Some(slug) = result.slug.as_ref().filter(|_| rolled_back) {
lines.push(format!(
"verdict: {}: the run failed after its first tag write and was rolled back \
automatically: {} restored and verified; no release or tag record was written",
escape_control(slug),
if result.outcome == ProjectOutcome::RolledBack {
"the tags and the project were"
} else {
"the tags were (the project was not imported)"
}
));
}
let unsynced = result.outcome == ProjectOutcome::AppliedBackupUnsynced;
if let Some(slug) = result.slug.as_ref().filter(|_| unsynced) {
let reason = result
.tags
.as_ref()
.and_then(|tags| tags.backup.as_ref())
.and_then(|backup| backup.reason)
.map_or("unknown", |reason| reason.as_str());
lines.push(format!(
"verdict: {}: the tags and the project are applied, verified and recorded on \
the master, and the backup did not prove it holds the owned tags ({reason}); \
nothing was rolled back",
escape_control(slug)
));
}
if let Some(slug) = result.slug.as_ref().filter(|_| !rolled_back && !unsynced) {
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)
));
}
}
lines.extend(owned_tag_lines(result));
lines.extend(tag_lines(result));
for warning in &result.warnings {
lines.push(format!("warning: {}", escape_control(warning)));
}
lines
}
const TAG_LINES_SHOWN: usize = 20;
fn owned_tag_lines(
result: &ignition_core::actions::deploy_projects::DeployProjectsResult,
) -> Vec<String> {
let Some(tags) = &result.tags else {
return Vec::new();
};
let mut lines = vec![format!(
"owned tags: {} provider(s), deployed before the project in the same run",
tags.providers.len()
)];
for p in &tags.providers {
lines.push(format!(
" {}: {} added, {} changed, {} same, {} live-only (kept)",
escape_control(&p.provider),
p.added,
p.changed,
p.same,
p.drift
));
if p.kept_values + p.removed + p.already_absent + p.non_canonical > 0 {
lines.push(format!(
" {}: {} kept value(s), {} removed, {} already absent, {} non-canonical file finding(s)",
escape_control(&p.provider),
p.kept_values,
p.removed,
p.already_absent,
p.non_canonical
));
}
if p.first_change {
lines.push(format!(
" {}: first deploy of the provider by this tool, changes need --allow-first-change",
escape_control(&p.provider)
));
}
}
let more = |lines: &mut Vec<String>, shown: usize, count: usize, what: &str| {
if count > shown {
lines.push(format!(" ... and {} more {what}", count - shown));
}
};
for path in tags.added.iter().take(TAG_LINES_SHOWN) {
lines.push(format!(" add {}", escape_control(path)));
}
more(
&mut lines,
tags.added.len().min(TAG_LINES_SHOWN),
tags.added_count,
"added",
);
for change in tags.changed.iter().take(TAG_LINES_SHOWN) {
lines.push(format!(
" changed {} ({}{})",
escape_control(&change.path),
change
.properties
.iter()
.map(|p| escape_control(p))
.collect::<Vec<_>>()
.join(", "),
if change.restart_likely {
"; restart likely"
} else {
""
}
));
}
more(
&mut lines,
tags.changed.len().min(TAG_LINES_SHOWN),
tags.changed_count,
"changed",
);
for path in tags.drift.iter().take(TAG_LINES_SHOWN) {
lines.push(format!(" live-only {}", escape_control(path)));
}
more(
&mut lines,
tags.drift.len().min(TAG_LINES_SHOWN),
tags.drift_count,
"live-only",
);
for path in tags.removed.iter().take(TAG_LINES_SHOWN) {
lines.push(format!(
" removed {} (deleted only with --allow-destructive)",
escape_control(path)
));
}
more(
&mut lines,
tags.removed.len().min(TAG_LINES_SHOWN),
tags.removed_count,
"removed",
);
for path in tags.kept_values.iter().take(TAG_LINES_SHOWN) {
lines.push(format!(" kept value {}", escape_control(path)));
}
more(
&mut lines,
tags.kept_values.len().min(TAG_LINES_SHOWN),
tags.kept_values_count,
"kept value(s)",
);
for finding in tags.non_canonical.iter().take(TAG_LINES_SHOWN) {
lines.push(format!(
" non-canonical {} ({})",
escape_control(&finding.file),
finding.reason
));
}
more(
&mut lines,
tags.non_canonical.len().min(TAG_LINES_SHOWN),
tags.non_canonical_count,
"non-canonical",
);
for path in tags.carried.iter().take(TAG_LINES_SHOWN) {
lines.push(format!(
" carried {} (Overwrite with its live value)",
escape_control(path)
));
}
more(
&mut lines,
tags.carried.len().min(TAG_LINES_SHOWN),
tags.carried_count,
"carried",
);
for path in tags.deleted.iter().take(TAG_LINES_SHOWN) {
lines.push(format!(" deleted {}", escape_control(path)));
}
more(
&mut lines,
tags.deleted.len().min(TAG_LINES_SHOWN),
tags.deleted_count,
"deleted",
);
if tags.deleted_count > 0 {
lines.push(format!(
" deleted: {} tag path(s) through the tag-delete route",
tags.deleted_count
));
}
if let Some(verify) = &tags.verify {
lines.push(format!(
" verify: {} after {} read(s) in {:.1} s{}",
if verify.equal { "equal" } else { "NOT EQUAL" },
verify.reads,
verify.elapsed_s,
if verify.settled { "" } else { " (unsettled)" }
));
}
if let Some(route) = &tags.delete_route {
lines.push(delete_route_line(route));
}
if let Some(rollback) = &tags.rollback {
lines.extend(rollback_lines(rollback, result.check));
}
if let Some(backup) = &tags.backup {
lines.push(format!(
" backup: {} after {} s{}{}",
match backup.reason {
None => "converged".to_string(),
Some(reason) => format!("NOT converged ({})", reason.as_str()),
},
backup.elapsed_s,
if backup.resync_sent {
", one resync sent to the master"
} else {
""
},
if backup.differing.is_empty() {
String::new()
} else {
format!(
"; differs: {}",
backup
.differing
.iter()
.map(|p| escape_control(p))
.collect::<Vec<_>>()
.join(", ")
)
}
));
}
for warning in &tags.warnings {
lines.push(format!(" warning: {}", escape_control(warning)));
}
lines.push(format!("tags sha256 {}", tags.tags_sha256));
lines.push(format!("live tags sha256 {}", tags.live_sha256));
lines.push(format!("tags plan_sha256 {}", tags.plan_sha256));
lines
}
fn delete_route_line(route: &ignition_core::deploy::tag_plan::TagDeleteRouteReport) -> String {
let names = |items: &[String]| {
items
.iter()
.map(|item| escape_control(item))
.collect::<Vec<_>>()
.join(", ")
};
if !route.configured {
return if route.missing.is_empty() {
" tag-delete route: not configured".to_string()
} else {
format!(
" tag-delete route: not configured (missing: {})",
names(&route.missing)
)
};
}
if !route.ready {
return format!(
" tag-delete route: NOT ready ({}); a rollback cannot remove created tags",
escape_control(route.problem.as_deref().unwrap_or("no handshake"))
);
}
format!(
" tag-delete route: ready (route version {}){}",
escape_control(route.route_version.as_deref().unwrap_or("unknown")),
if route.providers_not_allowed.is_empty() {
String::new()
} else {
format!(
"; it does not allow {}",
names(&route.providers_not_allowed)
)
}
)
}
fn rollback_lines(
rollback: &ignition_core::deploy::tag_plan::TagRollback,
check: bool,
) -> Vec<String> {
let mut lines = vec![format!(
" rollback ({}): {}",
escape_control(&rollback.cause),
escape_control(&rollback.detail)
)];
let restored = if check { "would restore" } else { "restored" };
let carried: std::collections::BTreeSet<&String> = rollback.carried.iter().collect();
for path in rollback.reimported.iter().take(TAG_LINES_SHOWN) {
lines.push(format!(
" {restored} {}{}",
escape_control(path),
if carried.contains(path) {
" (Overwrite with its live value)"
} else {
""
}
));
}
let shown = rollback.reimported.len().min(TAG_LINES_SHOWN);
if rollback.reimported_count > shown {
lines.push(format!(
" ... and {} more {restored}",
rollback.reimported_count - shown
));
}
let removed = if check { "would remove" } else { "removed" };
for path in rollback.deleted.iter().take(TAG_LINES_SHOWN) {
lines.push(format!(
" {removed} {} (created by the run, through the tag-delete route)",
escape_control(path)
));
}
let shown = rollback.deleted.len().min(TAG_LINES_SHOWN);
if rollback.deleted_count > shown {
lines.push(format!(
" ... and {} more {removed}",
rollback.deleted_count - shown
));
}
for path in rollback.undeletable.iter().take(TAG_LINES_SHOWN) {
lines.push(format!(
" cannot remove {} (created by the run; the tag-delete route cannot remove it)",
escape_control(path)
));
}
if rollback.groups_restored {
lines.push(" tag groups put back from the run's snapshot".to_string());
}
for provider in &rollback.provider_restored {
lines.push(format!(
" provider {}: settings put back",
escape_control(provider)
));
}
for provider in &rollback.provider_deleted {
lines.push(format!(
" provider {}: deleted (the run created it)",
escape_control(provider)
));
}
if rollback.project_reimported {
lines.push(" project imported again from its pre-image".to_string());
}
if let Some(verify) = &rollback.verify {
lines.push(format!(
" rollback verify: {} after {} read(s) in {:.1} s{}",
if verify.equal { "equal" } else { "NOT EQUAL" },
verify.reads,
verify.elapsed_s,
if verify.settled { "" } else { " (unsettled)" }
));
}
lines
}
fn tag_lines(
result: &ignition_core::actions::deploy_projects::DeployProjectsResult,
) -> Vec<String> {
let mut lines = Vec::new();
let unavailable = result
.missing_tags_unavailable
.as_deref()
.unwrap_or_default();
for entry in unavailable {
lines.push(format!(
"tags: NOT CHECKED ({}): {}",
entry
.provider
.as_deref()
.map_or_else(|| "every provider".to_string(), escape_control),
escape_control(&entry.reason)
));
}
let missing = result.missing_tags_count.unwrap_or(0);
if missing > 0 {
lines.push(format!(
"tags: {missing} tag path(s) the project uses are not on gateway {}",
escape_control(&result.gateway)
));
for tag in result.missing_tags.iter().flatten().take(TAG_LINES_SHOWN) {
lines.push(format!(
" {} used in {}",
escape_control(&tag.path),
tag.files
.iter()
.map(|file| escape_control(file))
.collect::<Vec<_>>()
.join(", ")
));
}
if missing > TAG_LINES_SHOWN {
lines.push(format!(" ... and {} more", missing - TAG_LINES_SHOWN));
}
}
let unknown = result.missing_tags_unknown_providers_count.unwrap_or(0);
if unknown > 0 {
lines.push(format!(
"tags: {unknown} provider(s) the project references are not on gateway {}",
escape_control(&result.gateway)
));
for entry in result
.missing_tags_unknown_providers
.iter()
.flatten()
.take(TAG_LINES_SHOWN)
{
lines.push(format!(
" {}: {} path(s), used in {}",
escape_control(&entry.provider),
entry.path_count,
entry
.files
.iter()
.map(|file| escape_control(file))
.collect::<Vec<_>>()
.join(", ")
));
}
if unknown > TAG_LINES_SHOWN {
lines.push(format!(" ... and {} more", unknown - TAG_LINES_SHOWN));
}
}
if unavailable.is_empty() && missing == 0 && unknown == 0 {
lines.push("tags: no missing tag paths found".to_string());
}
lines.push(format!(
"tags: {}",
ignition_core::deploy::project_tags::MISSING_TAGS_LIMIT
));
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}");
}
}
if let Some(backup) = &result.backup {
render_backup_report_human(backup);
}
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::TagsPreImageExported { provider, bytes } => format!(
"tag pre-image of provider {} saved to the run directory ({bytes} bytes)",
escape_control(provider)
),
ProjectsEvent::TagImportSent {
provider,
path,
policy,
nodes,
carry,
} => format!(
"tag import sent: {nodes} node(s) into provider {} below {} ({policy}{})",
escape_control(provider),
if path.is_empty() {
"the provider root".to_string()
} else {
escape_control(path)
},
if *carry { ", live values carried" } else { "" }
),
ProjectsEvent::TagValuesCarried {
provider,
path,
nodes,
} => format!(
"provider {} read again: the live values of {nodes} node(s) below {} are carried into the next import",
escape_control(provider),
if path.is_empty() {
"the provider root".to_string()
} else {
escape_control(path)
}
),
ProjectsEvent::TagGroupsWritten {
providers,
written,
deleted,
} => format!(
"tag groups of {} committed, scanned and read back ({written} file(s) written, {deleted} deleted)",
providers
.iter()
.map(|p| escape_control(p))
.collect::<Vec<_>>()
.join(", ")
),
ProjectsEvent::TagProviderSettingsApplied { provider, action } => format!(
"tag provider {} settings applied ({action}) and read back equal to the repo",
escape_control(provider)
),
ProjectsEvent::TagImportAnswered { provider, clean } => format!(
"tag import into provider {} answered{}",
escape_control(provider),
if *clean { "" } else { " with failures" }
),
ProjectsEvent::TagsVerified { provider, equal } => {
if *equal {
format!(
"tag verify green: provider {} holds the tags as the repo does",
escape_control(provider)
)
} else {
format!(
"tag verify NOT green: provider {} does not hold the tags as the repo does",
escape_control(provider)
)
}
}
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::TagRecordWritten { provider } => format!(
"tag record of provider {} written",
escape_control(provider)
),
ProjectsEvent::TagRecordFailed { provider, code } => format!(
"tag record of provider {} not written ({})",
escape_control(provider),
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::RollbackStarted { cause } => format!(
"rollback started ({}): putting back what the run wrote",
cause.as_str()
),
ProjectsEvent::ProjectRestored { equal } => {
if *equal {
"project imported again from its pre-image and read back equal".to_string()
} else {
"project imported again from its pre-image, NOT equal when read back".to_string()
}
}
ProjectsEvent::TagGroupsRestored { providers } => format!(
"tag groups of {} put back from the run's snapshot",
providers
.iter()
.map(|p| escape_control(p))
.collect::<Vec<_>>()
.join(", ")
),
ProjectsEvent::TagProviderRestored { provider, action } => {
if *action == "delete" {
format!(
"tag provider {} deleted (the run created it)",
escape_control(provider)
)
} else {
format!(
"tag provider {} settings put back from the pre-image",
escape_control(provider)
)
}
}
ProjectsEvent::RollbackVerified { equal } => {
if *equal {
"rollback verify green: the owned providers equal their pre-images".to_string()
} else {
"rollback verify NOT green: the owned providers differ from their pre-images"
.to_string()
}
}
ProjectsEvent::TagDeleteSent {
provider,
paths,
rollback,
} => format!(
"tag delete sent to the tag-delete route: provider {}, {} path(s){}",
escape_control(provider),
paths.len(),
if *rollback {
" the run created (rollback)"
} else {
""
}
),
ProjectsEvent::TagDeleteAnswered {
provider,
clean,
deleted,
} => format!(
"tag delete in provider {} answered: {deleted} removed{}",
escape_control(provider),
if *clean { "" } else { ", NOT clean" }
),
ProjectsEvent::HostPreImageWritten { files } => {
format!("pre-image kept on the gateway host ({files} file(s))")
}
ProjectsEvent::JournalWritten { state } => format!("host journal written: {state}"),
ProjectsEvent::JournalCleared => "host journal cleared".to_string(),
ProjectsEvent::BackupWaitStarted { budget_s } => {
format!("waiting up to {budget_s} s for the backup node to hold the owned tags")
}
ProjectsEvent::BackupResync { ok, code } => 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})"),
},
ProjectsEvent::BackupVerdict { converged, reason } => match reason {
Some(reason) => format!("backup node does not hold the owned tags ({reason})"),
None => format!("backup node holds the owned tags: converged={converged}"),
},
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())
);
for warning in &result.warnings {
println!("warning: {warning}");
}
}
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, delete_route_line, 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(),
missing_tags: Some(Vec::new()),
missing_tags_count: Some(0),
missing_tags_unknown_providers: Some(Vec::new()),
missing_tags_unknown_providers_count: Some(0),
missing_tags_unavailable: None,
missing_tags_limit: ignition_core::deploy::project_tags::MISSING_TAGS_LIMIT,
tags: None,
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 owned_tags_print_per_provider_and_escaped() {
use ignition_core::deploy::tag_plan::{ChangedTag, ProviderSummary, TagsReport};
let mut result = projects_result();
assert!(
!deploy_projects_human_lines(&result)
.iter()
.any(|l| l.starts_with("owned tags")),
"nothing for a project that owns no provider"
);
result.tags = Some(Box::new(TagsReport {
providers: vec![ProviderSummary {
provider: "PlantA".into(),
added: 1,
changed: 1,
same: 4,
drift: 22,
recreated: 0,
kept_values: 1,
removed: 1,
already_absent: 0,
non_canonical: 1,
first_change: false,
record_only: false,
record_run_id: Some("20261101T100000Z-ab12".into()),
}],
tags_sha256: "a".repeat(64),
live_sha256: "b".repeat(64),
added: vec!["[PlantA]Line1/Sp\u{1b}eed".to_string()],
added_count: 1,
changed: vec![ChangedTag {
path: "[PlantA]Line1/Pump".into(),
properties: vec!["parameters".into()],
restart_likely: true,
}],
changed_count: 1,
drift: (0..20).map(|i| format!("[PlantA]Old/T{i:02}")).collect(),
drift_count: 22,
warnings: vec!["no gateway-config manifest at gateways/g\u{1b}/manifest.yaml".into()],
plan_sha256: "c".repeat(64),
recreated: Vec::new(),
recreated_count: 0,
kept_values: vec!["[PlantA]Line1/Count".into()],
kept_values_count: 1,
removed: vec!["[PlantA]Line1/Gone\u{1b}".into()],
removed_count: 1,
already_absent: Vec::new(),
already_absent_count: 0,
non_canonical: vec![ignition_core::deploy::native_tags::NonCanonical {
file: "tags/PlantA/Line1/Count.json".into(),
reason: "trailing newline",
}],
non_canonical_count: 1,
carried: vec!["[PlantA]Line1/Ca\u{1b}rry".into()],
carried_count: 1,
verify: Some(ignition_core::deploy::tag_plan::TagVerify {
equal: true,
settled: true,
reads: 2,
elapsed_s: 5.04,
mismatches: Vec::new(),
mismatches_count: 0,
}),
provider_settings: Vec::new(),
groups: Vec::new(),
rollback: None,
journal: None,
backup: None,
delete_route: None,
deleted: vec!["[PlantA]Line1/Del\u{1b}eted".into()],
deleted_count: 4,
}));
let text = deploy_projects_human_lines(&result).join("\n");
for needle in [
"owned tags: 1 provider(s)",
" PlantA: 1 added, 1 changed, 4 same, 22 live-only (kept)",
" add [PlantA]Line1/Sp\\u{1b}eed",
" changed [PlantA]Line1/Pump (parameters; restart likely)",
" PlantA: 1 kept value(s), 1 removed, 0 already absent, 1 non-canonical file finding(s)",
" removed [PlantA]Line1/Gone\\u{1b} (deleted only with --allow-destructive)",
" deleted [PlantA]Line1/Del\\u{1b}eted",
" ... and 3 more deleted",
" deleted: 4 tag path(s) through the tag-delete route",
" carried [PlantA]Line1/Ca\\u{1b}rry (Overwrite with its live value)",
" verify: equal after 2 read(s) in 5.0 s",
" kept value [PlantA]Line1/Count",
" non-canonical tags/PlantA/Line1/Count.json (trailing newline)",
"tags plan_sha256 cccc",
" live-only [PlantA]Old/T19",
" ... and 2 more live-only",
" warning: no gateway-config manifest at gateways/g\\u{1b}/manifest.yaml",
] {
assert!(text.contains(needle), "{needle}: {text}");
}
assert!(
!text.chars().any(|c| c.is_control() && c != '\n'),
"{text:?}"
);
}
#[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 backup_unsynced_verdict_lines_name_the_reason() {
use ignition_core::actions::deploy_projects::{ProjectOutcome, ProjectsEvent};
use ignition_core::deploy::pair::UnsyncedReason;
use ignition_core::deploy::tag_converge::TagsBackupReport;
use ignition_core::deploy::tag_plan::{TagPlan, TagsReport};
let mut result = projects_result();
result.check = false;
result.outcome = ProjectOutcome::AppliedBackupUnsynced;
result.ok = false;
result.slug = Some("deploy_backup_unsynced".into());
result.record_written = true;
result.run_id = Some("r1".into());
result.run_dir = Some(".ign/deploy/gw/r1".into());
let plan = TagPlan {
providers: Vec::new(),
tags_sha256: "a".repeat(64),
live_sha256: "b".repeat(64),
plan_sha256: "c".repeat(64),
};
let mut tags = TagsReport::from_plan(&plan, &std::collections::BTreeMap::new(), 200);
tags.backup = Some(TagsBackupReport {
converged: false,
reason: Some(UnsyncedReason::ContentDiffers),
budget_s: 300,
elapsed_s: 600,
resync_sent: true,
providers: vec!["Plant\u{1b}A".into()],
differing: vec!["Plant\u{1b}A".into()],
});
result.tags = Some(Box::new(tags));
let text = deploy_projects_human_lines(&result).join("\n");
for needle in [
"outcome: applied_backup_unsynced (NOT verified)",
"verdict: deploy_backup_unsynced: the tags and the project are applied, verified and recorded on the master, and the backup did not prove it holds the owned tags (content_differs); nothing was rolled back",
" backup: NOT converged (content_differs) after 600 s, one resync sent to the master; differs: Plant\\u{1b}A",
] {
assert!(text.contains(needle), "{needle}: {text}");
}
assert!(!text.contains("does not equal the artifact"), "{text}");
assert!(
!text.chars().any(|c| c.is_control() && c != '\n'),
"{text:?}"
);
let line = |e| describe_projects_event(&e);
assert!(line(ProjectsEvent::BackupWaitStarted { budget_s: 300 }).contains("300 s"));
assert!(
line(ProjectsEvent::BackupVerdict {
converged: false,
reason: Some("not_syncing"),
})
.contains("(not_syncing)")
);
}
#[test]
fn rolled_back_verdict_lines_name_what_was_restored() {
use ignition_core::actions::deploy_projects::{
ProjectOutcome, ProjectsEvent, RollbackCause,
};
use ignition_core::deploy::tag_plan::{TagPlan, TagRollback, TagVerify, TagsReport};
let mut result = projects_result();
result.check = false;
result.outcome = ProjectOutcome::RolledBack;
result.ok = false;
result.slug = Some("deploy_rolled_back".into());
result.run_id = Some("r1".into());
result.run_dir = Some(".ign/deploy/gw/r1".into());
let plan = TagPlan {
providers: Vec::new(),
tags_sha256: "a".repeat(64),
live_sha256: "b".repeat(64),
plan_sha256: "c".repeat(64),
};
let mut tags = TagsReport::from_plan(&plan, &std::collections::BTreeMap::new(), 200);
tags.rollback = Some(TagRollback {
cause: "project_unverified".into(),
detail: "the project read back does not equal the artifact".into(),
reimported: vec!["[PlantA]Line1/Co\u{1b}unt".into(), "[PlantA]Line1/V".into()],
reimported_count: 2,
carried: vec!["[PlantA]Line1/V".into()],
carried_count: 1,
undeletable: Vec::new(),
undeletable_count: 0,
groups_restored: true,
provider_restored: vec!["Plant\u{1b}A".into()],
provider_deleted: vec!["PlantB".into()],
project_reimported: true,
verify: Some(TagVerify {
equal: true,
settled: true,
reads: 2,
elapsed_s: 0.5,
mismatches: Vec::new(),
mismatches_count: 0,
}),
deleted: Vec::new(),
deleted_count: 0,
});
result.tags = Some(Box::new(tags));
let text = deploy_projects_human_lines(&result).join("\n");
for needle in [
"outcome: rolled_back (NOT verified)",
"verdict: deploy_rolled_back: the run failed after its first tag write and was rolled back automatically: the tags and the project were restored and verified",
" rollback (project_unverified): the project read back does not equal the artifact",
" restored [PlantA]Line1/Co\\u{1b}unt",
" restored [PlantA]Line1/V (Overwrite with its live value)",
" tag groups put back from the run's snapshot",
" provider Plant\\u{1b}A: settings put back",
" provider PlantB: deleted (the run created it)",
" project imported again from its pre-image",
" rollback verify: equal after 2 read(s) in 0.5 s",
] {
assert!(text.contains(needle), "{needle}: {text}");
}
assert!(!text.contains("restore:"), "no restore command: {text}");
assert!(
!text.chars().any(|c| c.is_control() && c != '\n'),
"{text:?}"
);
result.outcome = ProjectOutcome::TagsRolledBack;
let text = deploy_projects_human_lines(&result).join("\n");
assert!(
text.contains("the tags were (the project was not imported) restored"),
"{text}"
);
let line = |e| describe_projects_event(&e);
assert!(
line(ProjectsEvent::RollbackStarted {
cause: RollbackCause::TagVerifyFailed
})
.contains("rollback started (tag_verify_failed)")
);
assert!(line(ProjectsEvent::RollbackVerified { equal: false }).contains("NOT green"));
assert!(line(ProjectsEvent::ProjectRestored { equal: true }).contains("read back equal"));
assert!(
line(ProjectsEvent::TagProviderRestored {
provider: "P\u{1b}".into(),
action: "delete"
})
.contains("P\\u{1b} deleted")
);
assert!(
line(ProjectsEvent::TagGroupsRestored {
providers: vec!["PlantA".into()]
})
.contains("PlantA put back")
);
}
#[test]
fn delete_route_and_removed_paths_lines() {
use ignition_core::actions::deploy_projects::{ProjectOutcome, ProjectsEvent};
use ignition_core::deploy::tag_plan::{
TagDeleteRouteReport, TagPlan, TagRollback, TagsReport,
};
let report = |configured: bool, ready: bool| TagDeleteRouteReport {
configured,
missing: if configured {
Vec::new()
} else {
vec!["webdev_tag_delete_secret".into(), "IGN\u{1b}USER".into()]
},
ready,
route_version: ready.then(|| "1.3.0".to_string()),
providers_not_allowed: if ready {
vec!["Plant\u{1b}B".into()]
} else {
Vec::new()
},
problem: (configured && !ready).then(|| "routes_not_deployed".to_string()),
};
assert_eq!(
delete_route_line(&report(false, false)),
" tag-delete route: not configured (missing: webdev_tag_delete_secret, IGN\\u{1b}USER)"
);
assert_eq!(
delete_route_line(&report(true, false)),
" tag-delete route: NOT ready (routes_not_deployed); a rollback cannot remove created tags"
);
assert_eq!(
delete_route_line(&report(true, true)),
" tag-delete route: ready (route version 1.3.0); it does not allow Plant\\u{1b}B"
);
let mut result = projects_result();
result.check = false;
result.outcome = ProjectOutcome::TagsRolledBack;
let plan = TagPlan {
providers: Vec::new(),
tags_sha256: "a".repeat(64),
live_sha256: "b".repeat(64),
plan_sha256: "c".repeat(64),
};
let mut tags = TagsReport::from_plan(&plan, &std::collections::BTreeMap::new(), 200);
tags.delete_route = Some(report(true, true));
tags.rollback = Some(TagRollback {
cause: "tag_step_failed".into(),
detail: "an import failed".into(),
reimported: Vec::new(),
reimported_count: 0,
carried: Vec::new(),
carried_count: 0,
undeletable: Vec::new(),
undeletable_count: 0,
groups_restored: false,
provider_restored: Vec::new(),
provider_deleted: Vec::new(),
project_reimported: false,
verify: None,
deleted: vec!["[PlantA]Line1/Sp\u{1b}eed".into()],
deleted_count: 1,
});
result.tags = Some(Box::new(tags));
let text = deploy_projects_human_lines(&result).join("\n");
for needle in [
" tag-delete route: ready (route version 1.3.0)",
" removed [PlantA]Line1/Sp\\u{1b}eed (created by the run, through the tag-delete route)",
] {
assert!(text.contains(needle), "{needle}: {text}");
}
assert!(
!text.chars().any(|c| c.is_control() && c != '\n'),
"{text:?}"
);
let line = |e| describe_projects_event(&e);
assert_eq!(
line(ProjectsEvent::TagDeleteSent {
provider: "PlantA".into(),
paths: vec!["Line1/Speed".into()],
rollback: true
}),
"tag delete sent to the tag-delete route: provider PlantA, 1 path(s) the run created \
(rollback)"
);
assert_eq!(
line(ProjectsEvent::TagDeleteAnswered {
provider: "PlantA".into(),
clean: false,
deleted: 0
}),
"tag delete in provider PlantA answered: 0 removed, NOT clean"
);
}
#[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!(line(ProjectsEvent::HostPreImageWritten { files: 4 }).contains("(4 file(s))"));
assert_eq!(
line(ProjectsEvent::JournalWritten {
state: "rolling_back"
}),
"host journal written: rolling_back"
);
assert_eq!(line(ProjectsEvent::JournalCleared), "host journal cleared");
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:?}"
);
}
fn missing(path: &str, files: &[&str]) -> ignition_core::deploy::project_tags::MissingTag {
ignition_core::deploy::project_tags::MissingTag {
path: path.to_string(),
files: files.iter().map(|f| (*f).to_string()).collect(),
}
}
#[test]
fn missing_tags_human_form_lists_paths_files_a_warning_and_the_limit() {
let mut result = projects_result();
result.missing_tags = Some(
(0..25)
.map(|n| {
missing(
&format!("[default]Area/T{n:02}"),
&["ignition/timer/Poll/handleTimerEvent.py", "ignition/b.py"],
)
})
.collect(),
);
result.missing_tags_count = Some(25);
result.warnings =
vec!["project App: 25 tag path(s) the project uses are not on gateway gw".to_string()];
let text = deploy_projects_human_lines(&result).join("\n");
assert!(text.contains("tags: 25 tag path(s)"), "{text}");
assert!(
text.contains(
" [default]Area/T00 used in ignition/timer/Poll/handleTimerEvent.py, ignition/b.py"
),
"{text}"
);
assert!(text.contains("[default]Area/T19"), "{text}");
assert!(!text.contains("[default]Area/T20"), "{text}");
assert!(text.contains(" ... and 5 more"), "{text}");
assert!(
text.contains("warning: project App: 25 tag path(s)"),
"{text}"
);
assert!(
text.contains(ignition_core::deploy::project_tags::MISSING_TAGS_LIMIT),
"{text}"
);
assert!(!text.contains("no missing"), "{text}");
}
#[test]
fn missing_tags_failed_read_prints_not_checked_and_never_no_missing() {
use ignition_core::deploy::project_tags::TagsUnavailable;
let mut result = projects_result();
result.missing_tags = None;
result.missing_tags_count = None;
result.missing_tags_unknown_providers = None;
result.missing_tags_unknown_providers_count = None;
result.missing_tags_unavailable = Some(vec![
TagsUnavailable {
provider: None,
reason: "the gateway's tag providers could not be listed (network_error)".into(),
},
TagsUnavailable {
provider: Some("default".into()),
reason: "the provider's tag export could not be read (auth_rejected)".into(),
},
]);
let text = deploy_projects_human_lines(&result).join("\n");
assert!(
text.contains("tags: NOT CHECKED (every provider): the gateway's tag providers"),
"{text}"
);
assert!(text.contains("tags: NOT CHECKED (default): "), "{text}");
assert!(!text.contains("no missing"), "{text}");
assert!(
text.contains(ignition_core::deploy::project_tags::MISSING_TAGS_LIMIT),
"{text}"
);
}
#[test]
fn missing_tags_no_missing_line_needs_every_list_empty() {
use ignition_core::deploy::project_tags::UnknownProvider;
let mut result = projects_result();
let text = deploy_projects_human_lines(&result).join("\n");
assert!(text.contains("tags: no missing tag paths found"), "{text}");
assert!(
text.contains(ignition_core::deploy::project_tags::MISSING_TAGS_LIMIT),
"the limit is always printed: {text}"
);
result.missing_tags_unknown_providers = Some(vec![UnknownProvider {
provider: "ghost".into(),
path_count: 2,
files: vec!["ignition/script-python/a/code.py".into()],
}]);
result.missing_tags_unknown_providers_count = Some(1);
let text = deploy_projects_human_lines(&result).join("\n");
assert!(!text.contains("no missing"), "{text}");
assert!(
text.contains("tags: 1 provider(s) the project references are not on gateway gw"),
"{text}"
);
assert!(
text.contains(" ghost: 2 path(s), used in ignition/script-python/a/code.py"),
"{text}"
);
}
#[test]
fn missing_tags_values_print_escaped() {
use ignition_core::deploy::project_tags::{TagsUnavailable, UnknownProvider};
let mut result = projects_result();
result.missing_tags = Some(vec![missing(
"[default]A/\u{1b}[2Jevil",
&["ignition/\u{1b}]0;x\u{7}.py"],
)]);
result.missing_tags_count = Some(1);
result.missing_tags_unknown_providers = Some(vec![UnknownProvider {
provider: "gh\u{1b}ost".into(),
path_count: 1,
files: vec!["a\u{1b}b.py".into()],
}]);
result.missing_tags_unknown_providers_count = Some(1);
result.missing_tags_unavailable = Some(vec![TagsUnavailable {
provider: Some("pro\u{1b}v".into()),
reason: "re\u{1b}ason".into(),
}]);
let text = deploy_projects_human_lines(&result).join("\n");
assert!(text.contains("[default]A/\\u{1b}[2Jevil"), "{text:?}");
assert!(text.contains("gh\\u{1b}ost"), "{text:?}");
assert!(text.contains("pro\\u{1b}v"), "{text:?}");
assert!(
!text.chars().any(|c| c.is_control() && c != '\n'),
"no raw control character reaches the terminal: {text:?}"
);
}
}