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 assert!(out.contains("invalid"));
157 assert!(!out.contains("key_id="));
158 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}