zoi-cli 1.24.2

Advanced Package Manager & Environment Orchestrator
Documentation
use anyhow::anyhow;
use colored::Colorize;
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;

/// Classifies the source and method used to install a package.
///
/// This is used for reporting and telemetry to understand where packages
/// are coming from in the ecosystem.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallOrigin {
    RegistryPrebuilt,
    RegistrySource,
    LocalArchive,
    LocalPackage,
    RemoteUrl,
    Unknown,
}

impl InstallOrigin {
    pub fn as_str(self) -> &'static str {
        match self {
            InstallOrigin::RegistryPrebuilt => "registry-prebuilt",
            InstallOrigin::RegistrySource => "registry-source",
            InstallOrigin::LocalArchive => "local-archive",
            InstallOrigin::LocalPackage => "local-package",
            InstallOrigin::RemoteUrl => "url",
            InstallOrigin::Unknown => "unknown",
        }
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct TransactionSummary {
    pub command: String,
    pub success: usize,
    pub failed: usize,
    pub skipped: usize,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PreflightRow {
    pub key: String,
    pub value: String,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PreflightSummary {
    pub title: String,
    pub rows: Vec<PreflightRow>,
}

impl PreflightSummary {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            rows: Vec::new(),
        }
    }

    pub fn row(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.rows.push(PreflightRow {
            key: key.into(),
            value: value.into(),
        });
        self
    }
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ExplainItem {
    pub subject: String,
    pub reason: String,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub details: Vec<String>,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ExplainReport {
    pub title: String,
    pub items: Vec<ExplainItem>,
}

impl ExplainReport {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            items: Vec::new(),
        }
    }

    pub fn item(
        mut self,
        subject: impl Into<String>,
        reason: impl Into<String>,
        details: Vec<String>,
    ) -> Self {
        self.items.push(ExplainItem {
            subject: subject.into(),
            reason: reason.into(),
            details,
        });
        self
    }
}

/// The standard JSON schema for Zoi execution plans (Specification v2).
///
/// This provides a stable, machine-readable interface for CI/CD pipelines
/// and external tools to understand what Zoi intends to do before it
/// performs any destructive actions.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct PlanJsonV1 {
    /// Schema version (currently "zoi.plan.v1").
    pub schema: String,
    /// The command that generated this plan (e.g. "install", "update").
    pub command: String,
    /// Command-specific fields (e.g. "packages", "totals", "dry_run").
    #[serde(flatten)]
    pub fields: BTreeMap<String, Value>,
}

impl PlanJsonV1 {
    pub fn new(command: impl Into<String>, fields: BTreeMap<String, Value>) -> Self {
        Self {
            schema: "zoi.plan.v1".to_string(),
            command: command.into(),
            fields,
        }
    }
}

pub fn print_preflight(summary: &PreflightSummary) {
    println!("\n{} {}", "::".bold().blue(), summary.title.bold());
    for row in &summary.rows {
        println!("  {:<24}{}", format!("{}:", row.key).cyan(), row.value);
    }
}

pub fn print_transaction_summary(summary: &TransactionSummary) {
    println!(
        "\n{} {} summary: success={}, failed={}, skipped={}",
        "::".bold().blue(),
        summary.command,
        summary.success.to_string().green(),
        summary.failed.to_string().red(),
        summary.skipped.to_string().yellow()
    );
}

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) {
    println!("\n{} {}", "::".bold().blue(), report.title);
    for item in &report.items {
        println!("  - {} {}", item.subject.cyan(), item.reason);
        for detail in &item.details {
            println!("    {}", detail.dimmed());
        }
    }
}

pub fn classify_source_origin(source: &str, action_name: &str) -> InstallOrigin {
    if source.starts_with("http://") || source.starts_with("https://") {
        return InstallOrigin::RemoteUrl;
    }
    if source.ends_with(".zpa") || source.ends_with(".pkg.tar.xz") || source.ends_with(".zsa") {
        return InstallOrigin::LocalArchive;
    }
    if (source.ends_with(".pkg.lua") || source.ends_with(".manifest.yaml"))
        && std::path::Path::new(source).exists()
    {
        return InstallOrigin::LocalPackage;
    }
    if action_name == "download" {
        InstallOrigin::RegistryPrebuilt
    } else if action_name == "build" {
        InstallOrigin::RegistrySource
    } else {
        InstallOrigin::Unknown
    }
}

pub fn with_failure_hint(command: &str, err: anyhow::Error) -> anyhow::Error {
    let msg = err.to_string();
    let hint = failure_hint(&msg, command);
    if let Some(hint_text) = hint {
        anyhow!("{}\nHint: {}", msg, hint_text)
    } else {
        err
    }
}

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);
        assert_eq!(summary.rows[0].key, "Scope");
        assert_eq!(summary.rows[1].value, "3");
    }
}