1use anyhow::anyhow;
2use colored::Colorize;
3use serde::Serialize;
4use serde_json::Value;
5use std::collections::BTreeMap;
6
7#[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#[derive(Debug, Clone, Serialize, PartialEq)]
114pub struct PlanJsonV1 {
115 pub schema: String,
117 pub command: String,
119 #[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}