Skip to main content

greentic_bundle/cli/info/
human.rs

1use 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    // Like kv but prefixed with a blank-line separator, used for trailing rows.
81    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    // Three-column layout: reference, version, digest.
91    // Version is blank when unknown (workspace input, external ref, or
92    // unreadable pack manifest) — users get the info we have.
93    //
94    // Column widths are tuned for short pack slugs (typical case). Long
95    // references (full OCI URLs, HTTP URLs) overflow the reference column
96    // but the trailing columns still line up relative to themselves, which
97    // is the important readability property when skimming versions.
98    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…")); // truncated at 20 chars + …
174        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        // Access section is always present, even with zero targets.
207        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        // Placeholder dash keeps columns aligned when version is unknown.
244        assert!(
245            out.contains("unbundled-pack") && out.contains("-"),
246            "expected placeholder dash for unknown version, got: {out}"
247        );
248    }
249}