Skip to main content

zoi_cli/cmd/
ux.rs

1use anyhow::anyhow;
2use colored::Colorize;
3use serde::Serialize;
4use serde_json::Value;
5use std::collections::BTreeMap;
6
7/// Classifies the source and method used to install a package.
8///
9/// This is used for reporting and telemetry to understand where packages
10/// are coming from in the ecosystem.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum InstallOrigin {
13    RegistryPrebuilt,
14    RegistrySource,
15    LocalArchive,
16    LocalPackage,
17    RemoteUrl,
18    Unknown,
19}
20
21impl InstallOrigin {
22    pub fn as_str(self) -> &'static str {
23        match self {
24            InstallOrigin::RegistryPrebuilt => "registry-prebuilt",
25            InstallOrigin::RegistrySource => "registry-source",
26            InstallOrigin::LocalArchive => "local-archive",
27            InstallOrigin::LocalPackage => "local-package",
28            InstallOrigin::RemoteUrl => "url",
29            InstallOrigin::Unknown => "unknown",
30        }
31    }
32}
33
34#[derive(Debug, Clone, Serialize)]
35pub struct TransactionSummary {
36    pub command: String,
37    pub success: usize,
38    pub failed: usize,
39    pub skipped: usize,
40}
41
42#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
43pub struct PreflightRow {
44    pub key: String,
45    pub value: String,
46}
47
48#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
49pub struct PreflightSummary {
50    pub title: String,
51    pub rows: Vec<PreflightRow>,
52}
53
54impl PreflightSummary {
55    pub fn new(title: impl Into<String>) -> Self {
56        Self {
57            title: title.into(),
58            rows: Vec::new(),
59        }
60    }
61
62    pub fn row(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
63        self.rows.push(PreflightRow {
64            key: key.into(),
65            value: value.into(),
66        });
67        self
68    }
69}
70
71#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
72pub struct ExplainItem {
73    pub subject: String,
74    pub reason: String,
75    #[serde(skip_serializing_if = "Vec::is_empty")]
76    pub details: Vec<String>,
77}
78
79#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
80pub struct ExplainReport {
81    pub title: String,
82    pub items: Vec<ExplainItem>,
83}
84
85impl ExplainReport {
86    pub fn new(title: impl Into<String>) -> Self {
87        Self {
88            title: title.into(),
89            items: Vec::new(),
90        }
91    }
92
93    pub fn item(
94        mut self,
95        subject: impl Into<String>,
96        reason: impl Into<String>,
97        details: Vec<String>,
98    ) -> Self {
99        self.items.push(ExplainItem {
100            subject: subject.into(),
101            reason: reason.into(),
102            details,
103        });
104        self
105    }
106}
107
108/// The standard JSON schema for Zoi execution plans (Specification v2).
109///
110/// This provides a stable, machine-readable interface for CI/CD pipelines
111/// and external tools to understand what Zoi intends to do before it
112/// performs any destructive actions.
113#[derive(Debug, Clone, Serialize, PartialEq)]
114pub struct PlanJsonV1 {
115    /// Schema version (currently "zoi.plan.v1").
116    pub schema: String,
117    /// The command that generated this plan (e.g. "install", "update").
118    pub command: String,
119    /// Command-specific fields (e.g. "packages", "totals", "dry_run").
120    #[serde(flatten)]
121    pub fields: BTreeMap<String, Value>,
122}
123
124impl PlanJsonV1 {
125    pub fn new(command: impl Into<String>, fields: BTreeMap<String, Value>) -> Self {
126        Self {
127            schema: "zoi.plan.v1".to_string(),
128            command: command.into(),
129            fields,
130        }
131    }
132}
133
134pub fn print_preflight(summary: &PreflightSummary) {
135    println!("\n{} {}", "::".bold().blue(), summary.title.bold());
136    for row in &summary.rows {
137        println!("  {:<24}{}", format!("{}:", row.key).cyan(), row.value);
138    }
139}
140
141pub fn print_transaction_summary(summary: &TransactionSummary) {
142    println!(
143        "\n{} {} summary: success={}, failed={}, skipped={}",
144        "::".bold().blue(),
145        summary.command,
146        summary.success.to_string().green(),
147        summary.failed.to_string().red(),
148        summary.skipped.to_string().yellow()
149    );
150}
151
152pub fn emit_plan_json<T: Serialize>(plan: &T) -> anyhow::Result<()> {
153    let json = serde_json::to_string_pretty(plan)?;
154    println!("{}", json);
155    Ok(())
156}
157
158pub fn emit_plan_json_v1(command: &str, payload: Value) -> anyhow::Result<()> {
159    let mut fields = BTreeMap::new();
160    match payload {
161        Value::Object(map) => {
162            for (key, value) in map {
163                fields.insert(key, value);
164            }
165        }
166        other => {
167            fields.insert("data".to_string(), other);
168        }
169    }
170    let plan = PlanJsonV1::new(command, fields);
171    emit_plan_json(&plan)
172}
173
174pub fn print_explain(report: &ExplainReport) {
175    println!("\n{} {}", "::".bold().blue(), report.title);
176    for item in &report.items {
177        println!("  - {} {}", item.subject.cyan(), item.reason);
178        for detail in &item.details {
179            println!("    {}", detail.dimmed());
180        }
181    }
182}
183
184pub fn classify_source_origin(source: &str, action_name: &str) -> InstallOrigin {
185    if source.starts_with("http://") || source.starts_with("https://") {
186        return InstallOrigin::RemoteUrl;
187    }
188    if source.ends_with(".zpa") || source.ends_with(".pkg.tar.xz") || source.ends_with(".zsa") {
189        return InstallOrigin::LocalArchive;
190    }
191    if (source.ends_with(".pkg.lua") || source.ends_with(".manifest.yaml"))
192        && std::path::Path::new(source).exists()
193    {
194        return InstallOrigin::LocalPackage;
195    }
196    if action_name == "download" {
197        InstallOrigin::RegistryPrebuilt
198    } else if action_name == "build" {
199        InstallOrigin::RegistrySource
200    } else {
201        InstallOrigin::Unknown
202    }
203}
204
205pub fn with_failure_hint(command: &str, err: anyhow::Error) -> anyhow::Error {
206    let msg = err.to_string();
207    let hint = failure_hint(&msg, command);
208    if let Some(hint_text) = hint {
209        anyhow!("{}\nHint: {}", msg, hint_text)
210    } else {
211        err
212    }
213}
214
215fn failure_hint(message: &str, command: &str) -> Option<&'static str> {
216    let m = message.to_lowercase();
217    if m.contains("not synced") || m.contains("registry") && m.contains("sync") {
218        return Some("Run `zoi sync` and retry.");
219    }
220    if m.contains("not enough disk space") {
221        return Some("Free space (e.g. `zoi clean`) and retry.");
222    }
223    if m.contains("policy") || m.contains("compliance") {
224        return Some("Review policy settings in config and rerun.");
225    }
226    if m.contains("vulnerab") || m.contains("advisory") {
227        return Some("Run `zoi audit` to inspect advisories before retrying.");
228    }
229    if m.contains("lockfile") {
230        return Some("Regenerate project lock state with a normal project install, then retry.");
231    }
232    if m.contains("hash verification failed") || m.contains("checksum") {
233        return Some("Resync metadata and retry; verify upstream archive integrity.");
234    }
235    if command == "uninstall" && m.contains("ambiguous package name") {
236        return Some("Specify an explicit source like `#handle@repo/name`.");
237    }
238    if command == "update" && m.contains("not installed") {
239        return Some("Use `zoi install` for new packages.");
240    }
241    None
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn classify_origin_remote_url() {
250        let origin = classify_source_origin("https://example.com/pkg.lua", "download");
251        assert_eq!(origin, InstallOrigin::RemoteUrl);
252    }
253
254    #[test]
255    fn classify_origin_registry_prebuilt() {
256        let origin = classify_source_origin("@core/hello", "download");
257        assert_eq!(origin, InstallOrigin::RegistryPrebuilt);
258    }
259
260    #[test]
261    fn appends_failure_hint_for_disk_errors() {
262        let err = anyhow!("Not enough disk space");
263        let with_hint = with_failure_hint("install", err).to_string();
264        assert!(with_hint.contains("Hint:"));
265    }
266
267    #[test]
268    fn plan_json_v1_has_schema_and_command() {
269        let mut fields = BTreeMap::new();
270        fields.insert("dry_run".to_string(), Value::Bool(true));
271        let plan = PlanJsonV1::new("install", fields);
272        assert_eq!(plan.schema, "zoi.plan.v1");
273        assert_eq!(plan.command, "install");
274    }
275
276    #[test]
277    fn preflight_summary_builder_collects_rows() {
278        let summary = PreflightSummary::new("Install preflight")
279            .row("Scope", "User")
280            .row("Retry attempts", "3");
281        assert_eq!(summary.rows.len(), 2);
282        assert_eq!(summary.rows[0].key, "Scope");
283        assert_eq!(summary.rows[1].value, "3");
284    }
285}