greentic_bundle/cli/info/
human.rs1use super::report::*;
2use std::fmt::Write;
3
4pub fn render(r: &InfoReport) -> String {
5 let mut s = String::new();
6 let header = match &r.version {
7 Some(v) => format!("{} {} · {}", r.name, v, r.mode),
8 None => format!("{} · {}", r.name, r.mode),
9 };
10 let _ = writeln!(s, "{header}");
11 if let Some(d) = &r.description {
12 let _ = writeln!(s, "{d}");
13 }
14 let _ = writeln!(s);
15
16 kv(&mut s, "Bundle ID", &r.bundle_id);
17 kv(&mut s, "Mode", &r.mode);
18 if !r.locale.is_empty() {
19 kv(&mut s, "Locale", &r.locale);
20 }
21
22 if !r.app_packs.is_empty() {
23 let _ = writeln!(s, "\nApp packs ({})", r.app_packs.len());
24 for p in &r.app_packs {
25 render_pack(&mut s, p);
26 }
27 }
28 if !r.extension_providers.is_empty() {
29 let _ = writeln!(s, "\nExtension providers ({})", r.extension_providers.len());
30 for p in &r.extension_providers {
31 render_pack(&mut s, p);
32 }
33 }
34 if !r.catalogs.is_empty() {
35 let _ = writeln!(s, "\nCatalogs ({})", r.catalogs.len());
36 for c in &r.catalogs {
37 let _ = writeln!(s, " {:<20} {} items", c.name, c.item_count);
38 }
39 }
40
41 let _ = writeln!(
42 s,
43 "\nAccess ({} tenants, {} teams)",
44 r.access.tenants, r.access.teams
45 );
46 for t in &r.access.targets {
47 let teams_text = if t.team_count == 1 {
48 "1 team".to_string()
49 } else {
50 format!("{} teams", t.team_count)
51 };
52 let _ = writeln!(
53 s,
54 " {:<12} {:<8} ({})",
55 t.tenant, teams_text, t.default_policy
56 );
57 }
58
59 if !r.capabilities.is_empty() {
60 kv_block(&mut s, "Capabilities", &r.capabilities.join(", "));
61 }
62 if !r.hooks.is_empty() {
63 kv_block(&mut s, "Hooks", &r.hooks.join(", "));
64 }
65 if !r.subscriptions.is_empty() {
66 kv_block(&mut s, "Subscriptions", &r.subscriptions.join(", "));
67 }
68
69 s
70}
71
72fn kv(s: &mut String, label: &str, value: &str) {
73 if value.is_empty() {
74 return;
75 }
76 let _ = writeln!(s, "{:<14} {}", label, value);
77}
78
79fn kv_block(s: &mut String, label: &str, value: &str) {
80 let _ = writeln!(s, "\n{:<14} {}", label, value);
82}
83
84fn render_pack(s: &mut String, p: &PackRef) {
85 let digest_display = match &p.digest {
86 Some(d) if d.len() > 24 => format!("{}…", &d[..24]),
87 Some(d) => d.clone(),
88 None => "(no digest)".into(),
89 };
90 let version_display: &str = p.version.as_deref().unwrap_or("-");
99 let _ = writeln!(
100 s,
101 " {:<24} {:<10} {}",
102 p.reference, version_display, digest_display
103 );
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 fn sample() -> InfoReport {
111 InfoReport {
112 info_schema_version: 1,
113 bundle_id: "acme".into(),
114 name: "acme-demo".into(),
115 version: None,
116 description: Some("Demo bundle for ACME.".into()),
117 mode: "production".into(),
118 locale: "en".into(),
119 app_packs: vec![
120 PackRef {
121 reference: "hello-bot".into(),
122 version: None,
123 digest: Some("sha256:abcdef123456abcdef".into()),
124 },
125 PackRef {
126 reference: "support-bot".into(),
127 version: None,
128 digest: None,
129 },
130 ],
131 extension_providers: vec![PackRef {
132 reference: "slack-provider".into(),
133 version: None,
134 digest: Some("sha256:9ab0cd1234".into()),
135 }],
136 catalogs: vec![CatalogRef {
137 name: "catalog.json".into(),
138 item_count: 12,
139 }],
140 access: AccessSummary {
141 tenants: 2,
142 teams: 3,
143 targets: vec![
144 AccessTarget {
145 tenant: "default".into(),
146 team_count: 2,
147 default_policy: "public".into(),
148 },
149 AccessTarget {
150 tenant: "acme".into(),
151 team_count: 1,
152 default_policy: "forbidden".into(),
153 },
154 ],
155 },
156 capabilities: vec!["state.kv".into(), "secrets".into()],
157 hooks: vec!["on_install".into()],
158 subscriptions: vec![],
159 }
160 }
161
162 #[test]
163 fn renders_header_mode_and_description() {
164 let out = render(&sample());
165 assert!(out.contains("acme-demo · production"));
166 assert!(out.contains("Demo bundle for ACME."));
167 }
168
169 #[test]
170 fn renders_packs_with_digest_and_fallback() {
171 let out = render(&sample());
172 assert!(out.contains("hello-bot"));
173 assert!(out.contains("sha256:abcdef123456abcde…")); assert!(out.contains("support-bot"));
175 assert!(out.contains("(no digest)"));
176 }
177
178 #[test]
179 fn renders_access_summary() {
180 let out = render(&sample());
181 assert!(out.contains("Access (2 tenants, 3 teams)"));
182 assert!(out.contains("default"));
183 assert!(out.contains("2 teams"));
184 assert!(out.contains("(public)"));
185 assert!(out.contains("acme"));
186 assert!(out.contains("1 team"));
187 assert!(out.contains("(forbidden)"));
188 }
189
190 #[test]
191 fn omits_empty_sections() {
192 let mut r = sample();
193 r.app_packs.clear();
194 r.extension_providers.clear();
195 r.catalogs.clear();
196 r.capabilities.clear();
197 r.hooks.clear();
198 r.subscriptions.clear();
199 let out = render(&r);
200 assert!(!out.contains("App packs"));
201 assert!(!out.contains("Extension providers"));
202 assert!(!out.contains("Catalogs"));
203 assert!(!out.contains("Capabilities"));
204 assert!(!out.contains("Hooks"));
205 assert!(!out.contains("Subscriptions"));
206 assert!(out.contains("Access (2 tenants, 3 teams)"));
208 }
209
210 #[test]
211 fn renders_with_version_in_header() {
212 let mut r = sample();
213 r.version = Some("0.3.0".into());
214 let out = render(&r);
215 assert!(out.contains("acme-demo 0.3.0 · production"));
216 }
217
218 #[test]
219 fn renders_pack_version_when_present() {
220 let mut r = sample();
221 r.extension_providers = vec![PackRef {
222 reference: "messaging-webchat-gui".into(),
223 version: Some("0.4.86".into()),
224 digest: Some("sha256:deadbeefcafef00d".into()),
225 }];
226 let out = render(&r);
227 assert!(
228 out.contains("messaging-webchat-gui"),
229 "pack reference missing: {out}"
230 );
231 assert!(out.contains("0.4.86"), "pack version missing: {out}");
232 }
233
234 #[test]
235 fn renders_placeholder_when_pack_version_missing() {
236 let mut r = sample();
237 r.extension_providers = vec![PackRef {
238 reference: "unbundled-pack".into(),
239 version: None,
240 digest: None,
241 }];
242 let out = render(&r);
243 assert!(
245 out.contains("unbundled-pack") && out.contains("-"),
246 "expected placeholder dash for unknown version, got: {out}"
247 );
248 }
249}