Skip to main content

packc/cli/info/
human.rs

1use super::report::{ComponentInfo, InfoReport, SignatureInfo, SignatureStatus};
2use std::fmt::Write;
3
4pub fn render(r: &InfoReport) -> String {
5    let mut s = String::new();
6    let header = match &r.kind {
7        Some(k) => format!("{} {} · {}", r.name, r.version, k),
8        None => format!("{} {}", r.name, r.version),
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    if !r.authors.is_empty() {
17        kv(&mut s, "Authors", &r.authors.join(", "));
18    }
19    if let Some(v) = &r.license {
20        kv(&mut s, "License", v);
21    }
22    if let Some(v) = &r.homepage {
23        kv(&mut s, "Homepage", v);
24    }
25    kv(&mut s, "Created", &r.created_at_utc);
26    kv(&mut s, "Signature", &signature_line(&r.signature));
27
28    if !r.components.is_empty() {
29        let _ = writeln!(s, "\nComponents ({})", r.components.len());
30        for c in &r.components {
31            render_component(&mut s, c);
32        }
33    }
34
35    if !r.entry_flows.is_empty() {
36        let _ = writeln!(s, "\nEntry flows ({})", r.entry_flows.len());
37        for f in &r.entry_flows {
38            let _ = writeln!(s, "  {f}");
39        }
40    }
41
42    if !r.imports.is_empty() {
43        let _ = writeln!(s, "\nImports ({})", r.imports.len());
44        for i in &r.imports {
45            let _ = writeln!(s, "  {} {}", i.pack_id, i.version_req);
46        }
47    }
48
49    if !r.interfaces.is_empty() {
50        let _ = writeln!(s, "\nInterfaces ({})", r.interfaces.len());
51        for i in &r.interfaces {
52            let _ = writeln!(s, "  {i}");
53        }
54    }
55
56    s
57}
58
59fn kv(s: &mut String, label: &str, value: &str) {
60    if value.is_empty() {
61        return;
62    }
63    let _ = writeln!(s, "{:<12} {}", label, value);
64}
65
66fn render_component(s: &mut String, c: &ComponentInfo) {
67    let kind = c.kind.as_deref().unwrap_or("");
68    let _ = writeln!(s, "  {:<28} {:<10} {}", c.component_id, c.version, kind);
69}
70
71fn signature_line(sig: &SignatureInfo) -> String {
72    match (sig.status, &sig.key_id) {
73        (SignatureStatus::Signed, Some(id)) => format!("signed · key_id={id}"),
74        (SignatureStatus::Signed, None) => "signed".into(),
75        (SignatureStatus::Unsigned, _) => "unsigned".into(),
76        (SignatureStatus::Invalid, Some(id)) => format!("invalid · key_id={id}"),
77        (SignatureStatus::Invalid, None) => "invalid".into(),
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::super::report::*;
84    use super::*;
85
86    fn sample(sig: SignatureInfo) -> InfoReport {
87        InfoReport {
88            info_schema_version: 1,
89            name: "hello-bot".into(),
90            version: "1.2.3".into(),
91            kind: Some("application".into()),
92            description: Some("A demo".into()),
93            authors: vec!["Alice".into()],
94            license: Some("MIT".into()),
95            homepage: Some("https://greentic.ai/packs/hello-bot".into()),
96            support: None,
97            vendor: None,
98            created_at_utc: "2026-01-01T00:00:00Z".into(),
99            signature: sig,
100            components: vec![ComponentInfo {
101                component_id: "messaging/slack".into(),
102                version: "1.4.0".into(),
103                kind: Some("messaging".into()),
104            }],
105            entry_flows: vec!["flows/a.ygtc".into()],
106            imports: vec![ImportInfo {
107                pack_id: "greentic/core".into(),
108                version_req: "^0.6.0".into(),
109            }],
110            interfaces: vec!["greentic:component@0.6.0".into()],
111        }
112    }
113
114    #[test]
115    fn signed_pack_renders_header_and_key_id() {
116        let out = render(&sample(SignatureInfo {
117            status: SignatureStatus::Signed,
118            key_id: Some("ed25519:abc".into()),
119        }));
120        assert!(out.contains("hello-bot 1.2.3 · application"));
121        assert!(out.contains("A demo"));
122        assert!(out.contains("signed · key_id=ed25519:abc"));
123        assert!(out.contains("messaging/slack"));
124        assert!(out.contains("greentic/core ^0.6.0"));
125        assert!(out.contains("greentic:component@0.6.0"));
126    }
127
128    #[test]
129    fn unsigned_pack_renders_unsigned() {
130        let out = render(&sample(SignatureInfo {
131            status: SignatureStatus::Unsigned,
132            key_id: None,
133        }));
134        assert!(out.contains("unsigned"));
135        assert!(!out.contains("key_id="));
136    }
137
138    #[test]
139    fn invalid_signature_with_key_id_renders_invalid_and_key_id() {
140        let out = render(&sample(SignatureInfo {
141            status: SignatureStatus::Invalid,
142            key_id: Some("ed25519:bad".into()),
143        }));
144        assert!(out.contains("invalid · key_id=ed25519:bad"));
145        assert!(!out.contains("signed"));
146        assert!(!out.contains("unsigned"));
147    }
148
149    #[test]
150    fn invalid_signature_without_key_id_renders_bare_invalid() {
151        let out = render(&sample(SignatureInfo {
152            status: SignatureStatus::Invalid,
153            key_id: None,
154        }));
155        // Must contain "invalid" on the Signature row but NOT "key_id=".
156        assert!(out.contains("invalid"));
157        assert!(!out.contains("key_id="));
158        // Also must not accidentally render "signed" or "unsigned" as the status.
159        let signature_line = out.lines().find(|l| l.starts_with("Signature")).unwrap();
160        assert_eq!(signature_line.trim(), "Signature    invalid");
161    }
162
163    #[test]
164    fn omits_sections_when_empty() {
165        let r = InfoReport {
166            info_schema_version: 1,
167            name: "min".into(),
168            version: "0.1.0".into(),
169            kind: None,
170            description: None,
171            authors: vec![],
172            license: None,
173            homepage: None,
174            support: None,
175            vendor: None,
176            created_at_utc: "2026-01-01T00:00:00Z".into(),
177            signature: SignatureInfo {
178                status: SignatureStatus::Unsigned,
179                key_id: None,
180            },
181            components: vec![],
182            entry_flows: vec![],
183            imports: vec![],
184            interfaces: vec![],
185        };
186        let out = render(&r);
187        assert!(out.contains("min 0.1.0"));
188        assert!(!out.contains("Authors"));
189        assert!(!out.contains("Components"));
190        assert!(!out.contains("Entry flows"));
191        assert!(!out.contains("Imports"));
192        assert!(!out.contains("Interfaces"));
193    }
194}