Skip to main content

zoi_cli/cmd/
ux.rs

1//! User experience (UX) utilities for the Zoi CLI.
2//!
3//! This module contains types and functions for formatting output,
4//! providing hints to the user, and emitting machine-readable plans.
5
6use std::collections::BTreeMap;
7
8use anyhow::anyhow;
9use colored::Colorize;
10use serde::Serialize;
11use serde_json::Value;
12pub use zoi_common::ux::*;
13
14/// Prints a preflight summary to the console.
15pub fn print_preflight(summary: &PreflightSummary) {
16    let title = summary.title.bold();
17    println!("\n{} {title}", "::".bold().blue());
18    for row in &summary.rows {
19        let key = format!("{}:", row.key).cyan();
20        println!("  {:<24}{}", key, row.value);
21    }
22}
23
24/// Prints a transaction summary to the console.
25pub fn print_transaction_summary(summary: &TransactionSummary) {
26    let command = &summary.command;
27    let success = summary.success.to_string().green();
28    let failed = summary.failed.to_string().red();
29    let skipped = summary.skipped.to_string().yellow();
30    println!(
31        "\n{} {command} summary: success={success}, failed={failed}, \
32         skipped={skipped}",
33        "::".bold().blue()
34    );
35}
36
37/// Emits a plan in JSON format to stdout.
38///
39/// # Errors
40///
41/// Returns an error if the plan cannot be serialized to JSON.
42pub fn emit_plan_json<T: Serialize>(plan: &T) -> anyhow::Result<()> {
43    let json = serde_json::to_string_pretty(plan)?;
44    println!("{json}");
45    Ok(())
46}
47
48/// Emits a version 1 plan in JSON format to stdout.
49///
50/// # Errors
51///
52/// Returns an error if the plan cannot be serialized to JSON.
53pub fn emit_plan_json_v1(command: &str, payload: Value) -> anyhow::Result<()> {
54    let mut fields = BTreeMap::new();
55    match payload {
56        Value::Object(map) => {
57            for (key, value) in map {
58                fields.insert(key, value);
59            }
60        }
61        other => {
62            fields.insert("data".to_string(), other);
63        }
64    }
65    let plan = PlanJsonV1::new(command, fields);
66    emit_plan_json(&plan)
67}
68
69/// Prints an explanation report to the console.
70pub fn print_explain(report: &ExplainReport) {
71    let title = &report.title;
72    println!("\n{} {title}", "::".bold().blue());
73    for item in &report.items {
74        let subject = item.subject.cyan();
75        let reason = &item.reason;
76        println!("  - {subject} {reason}");
77        for detail in &item.details {
78            let detail = detail.dimmed();
79            println!("    {detail}");
80        }
81    }
82}
83
84/// Classifies the origin of a package based on its source string and the action
85/// being performed.
86#[must_use]
87pub fn classify_source_origin(
88    source: &str,
89    action_name: &str
90) -> InstallOrigin {
91    if source.starts_with("http://") || source.starts_with("https://") {
92        return InstallOrigin::RemoteUrl;
93    }
94    let path = std::path::Path::new(source);
95    if path.extension().is_some_and(|ext| {
96        ext.eq_ignore_ascii_case("zpa")
97            || ext.eq_ignore_ascii_case("zsa")
98            || source.ends_with(".pkg.tar.zst")
99    }) {
100        return InstallOrigin::LocalArchive;
101    }
102    if (source.ends_with(".pkg.lua")
103        || source.ends_with(".manifest.yaml")
104        || source.ends_with(".manifest.json"))
105        && path.exists()
106    {
107        return InstallOrigin::LocalPackage;
108    }
109    if action_name == "download" {
110        InstallOrigin::RegistryPrebuilt
111    } else if action_name == "build" {
112        InstallOrigin::RegistrySource
113    } else {
114        InstallOrigin::Unknown
115    }
116}
117
118/// Formats a package identifier for display based on registry and repository
119/// configuration.
120pub fn format_display_name(
121    registry: &str,
122    repo: &str,
123    name: &str,
124    sub: Option<&str>,
125    config: &zoi_core::types::Config
126) -> String {
127    let base_name = if let Some(s) = sub {
128        format!("{name}:{s}")
129    } else {
130        name.to_string()
131    };
132
133    if registry == "local" && repo.starts_with("git/") {
134        let repo_name = &repo[4..];
135        return format!("#git@{repo_name}/{base_name}");
136    }
137
138    let default_handle = config
139        .default_registry
140        .as_ref()
141        .map_or("", |r| r.handle.as_str());
142    let active_repos = &config.repos;
143
144    if registry == default_handle || registry == "local" || registry.is_empty()
145    {
146        if active_repos.contains(&repo.to_string()) || repo.is_empty() {
147            base_name
148        } else {
149            format!("@{repo}/{base_name}")
150        }
151    } else {
152        format!("#{registry}@{repo}/{base_name}")
153    }
154}
155
156/// Wraps an error with a user-friendly hint if one is available for the given
157/// error message and command.
158#[must_use]
159pub fn with_failure_hint(command: &str, err: anyhow::Error) -> anyhow::Error {
160    let msg = err.to_string();
161    let hint = failure_hint(&msg, command);
162    hint.map_or(err, |hint_text| anyhow!("{msg}\nHint: {hint_text}"))
163}
164
165/// Returns a user-friendly hint for a given error message and command.
166fn failure_hint(message: &str, command: &str) -> Option<&'static str> {
167    let m = message.to_lowercase();
168    if m.contains("not synced") || m.contains("registry") && m.contains("sync")
169    {
170        return Some("Run `zoi sync` and retry.");
171    }
172    if m.contains("not enough disk space") {
173        return Some("Free space (e.g. `zoi cache clear`) and retry.");
174    }
175    if m.contains("policy") || m.contains("compliance") {
176        return Some("Review policy settings in config and rerun.");
177    }
178    if m.contains("vulnerab") || m.contains("advisory") {
179        return Some("Run `zoi audit` to inspect advisories before retrying.");
180    }
181    if m.contains("lockfile") {
182        return Some(
183            "Regenerate project lock state with a normal project install, \
184             then retry."
185        );
186    }
187    if m.contains("hash verification failed") || m.contains("checksum") {
188        return Some(
189            "Resync metadata (`zoi sync --force`) and retry with `zoi install \
190             --force` to re-download the archive; verify upstream archive \
191             integrity."
192        );
193    }
194    if command == "uninstall" && m.contains("ambiguous package name") {
195        return Some("Specify an explicit source like `#handle@repo/name`.");
196    }
197    if command == "update" && m.contains("not installed") {
198        return Some("Use `zoi install` for new packages.");
199    }
200    None
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn classify_origin_remote_url() {
209        let origin =
210            classify_source_origin("https://example.com/pkg.lua", "download");
211        assert_eq!(origin, InstallOrigin::RemoteUrl);
212    }
213
214    #[test]
215    fn classify_origin_registry_prebuilt() {
216        let origin = classify_source_origin("@core/hello", "download");
217        assert_eq!(origin, InstallOrigin::RegistryPrebuilt);
218    }
219
220    #[test]
221    fn appends_failure_hint_for_disk_errors() {
222        let err = anyhow!("Not enough disk space");
223        let with_hint = with_failure_hint("install", err).to_string();
224        assert!(with_hint.contains("Hint:"));
225    }
226
227    #[test]
228    fn plan_json_v1_has_schema_and_command() {
229        let mut fields = BTreeMap::new();
230        fields.insert("dry_run".to_string(), Value::Bool(true));
231        let plan = PlanJsonV1::new("install", fields);
232        assert_eq!(plan.schema, "zoi.plan.v1");
233        assert_eq!(plan.command, "install");
234    }
235
236    #[test]
237    fn preflight_summary_builder_collects_rows() {
238        let summary = PreflightSummary::new("Install preflight")
239            .row("Scope", "User")
240            .row("Retry attempts", "3");
241        assert_eq!(summary.rows.len(), 2);
242        if let Some(row) = summary.rows.first() {
243            assert_eq!(row.key, "Scope");
244        }
245        if let Some(row) = summary.rows.get(1) {
246            assert_eq!(row.value, "3");
247        }
248    }
249}