use std::collections::BTreeMap;
use anyhow::anyhow;
use colored::Colorize;
use serde::Serialize;
use serde_json::Value;
pub use zoi_common::ux::*;
pub fn print_preflight(summary: &PreflightSummary) {
let title = summary.title.bold();
println!("\n{} {title}", "::".bold().blue());
for row in &summary.rows {
let key = format!("{}:", row.key).cyan();
println!(" {:<24}{}", key, row.value);
}
}
pub fn print_transaction_summary(summary: &TransactionSummary) {
let command = &summary.command;
let success = summary.success.to_string().green();
let failed = summary.failed.to_string().red();
let skipped = summary.skipped.to_string().yellow();
println!(
"\n{} {command} summary: success={success}, failed={failed}, \
skipped={skipped}",
"::".bold().blue()
);
}
pub fn emit_plan_json<T: Serialize>(plan: &T) -> anyhow::Result<()> {
let json = serde_json::to_string_pretty(plan)?;
println!("{json}");
Ok(())
}
pub fn emit_plan_json_v1(command: &str, payload: Value) -> anyhow::Result<()> {
let mut fields = BTreeMap::new();
match payload {
Value::Object(map) => {
for (key, value) in map {
fields.insert(key, value);
}
}
other => {
fields.insert("data".to_string(), other);
}
}
let plan = PlanJsonV1::new(command, fields);
emit_plan_json(&plan)
}
pub fn print_explain(report: &ExplainReport) {
let title = &report.title;
println!("\n{} {title}", "::".bold().blue());
for item in &report.items {
let subject = item.subject.cyan();
let reason = &item.reason;
println!(" - {subject} {reason}");
for detail in &item.details {
let detail = detail.dimmed();
println!(" {detail}");
}
}
}
#[must_use]
pub fn classify_source_origin(
source: &str,
action_name: &str
) -> InstallOrigin {
if source.starts_with("http://") || source.starts_with("https://") {
return InstallOrigin::RemoteUrl;
}
let path = std::path::Path::new(source);
if path.extension().is_some_and(|ext| {
ext.eq_ignore_ascii_case("zpa")
|| ext.eq_ignore_ascii_case("zsa")
|| source.ends_with(".pkg.tar.zst")
}) {
return InstallOrigin::LocalArchive;
}
if (source.ends_with(".pkg.lua") || source.ends_with(".manifest.yaml"))
&& path.exists()
{
return InstallOrigin::LocalPackage;
}
if action_name == "download" {
InstallOrigin::RegistryPrebuilt
} else if action_name == "build" {
InstallOrigin::RegistrySource
} else {
InstallOrigin::Unknown
}
}
#[must_use]
pub fn with_failure_hint(command: &str, err: anyhow::Error) -> anyhow::Error {
let msg = err.to_string();
let hint = failure_hint(&msg, command);
hint.map_or(err, |hint_text| anyhow!("{msg}\nHint: {hint_text}"))
}
fn failure_hint(message: &str, command: &str) -> Option<&'static str> {
let m = message.to_lowercase();
if m.contains("not synced") || m.contains("registry") && m.contains("sync")
{
return Some("Run `zoi sync` and retry.");
}
if m.contains("not enough disk space") {
return Some("Free space (e.g. `zoi clean`) and retry.");
}
if m.contains("policy") || m.contains("compliance") {
return Some("Review policy settings in config and rerun.");
}
if m.contains("vulnerab") || m.contains("advisory") {
return Some("Run `zoi audit` to inspect advisories before retrying.");
}
if m.contains("lockfile") {
return Some(
"Regenerate project lock state with a normal project install, \
then retry."
);
}
if m.contains("hash verification failed") || m.contains("checksum") {
return Some(
"Resync metadata and retry; verify upstream archive integrity."
);
}
if command == "uninstall" && m.contains("ambiguous package name") {
return Some("Specify an explicit source like `#handle@repo/name`.");
}
if command == "update" && m.contains("not installed") {
return Some("Use `zoi install` for new packages.");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_origin_remote_url() {
let origin =
classify_source_origin("https://example.com/pkg.lua", "download");
assert_eq!(origin, InstallOrigin::RemoteUrl);
}
#[test]
fn classify_origin_registry_prebuilt() {
let origin = classify_source_origin("@core/hello", "download");
assert_eq!(origin, InstallOrigin::RegistryPrebuilt);
}
#[test]
fn appends_failure_hint_for_disk_errors() {
let err = anyhow!("Not enough disk space");
let with_hint = with_failure_hint("install", err).to_string();
assert!(with_hint.contains("Hint:"));
}
#[test]
fn plan_json_v1_has_schema_and_command() {
let mut fields = BTreeMap::new();
fields.insert("dry_run".to_string(), Value::Bool(true));
let plan = PlanJsonV1::new("install", fields);
assert_eq!(plan.schema, "zoi.plan.v1");
assert_eq!(plan.command, "install");
}
#[test]
fn preflight_summary_builder_collects_rows() {
let summary = PreflightSummary::new("Install preflight")
.row("Scope", "User")
.row("Retry attempts", "3");
assert_eq!(summary.rows.len(), 2);
if let Some(row) = summary.rows.first() {
assert_eq!(row.key, "Scope");
}
if let Some(row) = summary.rows.get(1) {
assert_eq!(row.value, "3");
}
}
}