1use std::collections::BTreeMap;
7
8use anyhow::anyhow;
9use colored::Colorize;
10use serde::Serialize;
11use serde_json::Value;
12pub use zoi_common::ux::*;
13
14pub 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
24pub 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
37pub 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
48pub 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
69pub 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#[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
118pub 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#[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
165fn 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}