1use anyhow::{Context, Result};
2use greentic_bundle_reader::{
3 BundleLock, BundleManifest, BundleResolvedTargetView, BundleSourceKind, DependencyLock,
4 OpenedBundle,
5};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10use super::pack_probe::{self, PackMetaSlim};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct InfoReport {
14 pub info_schema_version: u32,
15 pub bundle_id: String,
16 pub name: String,
17 pub version: Option<String>,
18 pub description: Option<String>,
19 pub mode: String,
20 pub locale: String,
21 pub app_packs: Vec<PackRef>,
22 pub extension_providers: Vec<PackRef>,
23 pub catalogs: Vec<CatalogRef>,
24 pub access: AccessSummary,
25 pub capabilities: Vec<String>,
26 pub hooks: Vec<String>,
27 pub subscriptions: Vec<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PackRef {
32 pub reference: String,
33 pub version: Option<String>,
34 pub digest: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CatalogRef {
39 pub name: String,
40 pub item_count: u32,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AccessSummary {
45 pub tenants: u32,
46 pub teams: u32,
47 pub targets: Vec<AccessTarget>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct AccessTarget {
52 pub tenant: String,
53 pub team_count: u32,
54 pub default_policy: String,
55}
56
57impl InfoReport {
58 pub fn from_opened_bundle(opened: &OpenedBundle) -> Self {
66 let meta = pack_metadata_for(opened);
67 project(&opened.manifest, &opened.lock, &meta)
68 }
69
70 pub fn from_workspace(path: &Path) -> Result<Self> {
75 let report = crate::build::inspect_target(Some(path), None)
76 .with_context(|| format!("reading bundle workspace at {}", path.display()))?;
77 Ok(project(&report.manifest, &report.lock, &BTreeMap::new()))
78 }
79}
80
81fn pack_metadata_for(opened: &OpenedBundle) -> BTreeMap<String, PackMetaSlim> {
84 if !matches!(opened.source_kind, BundleSourceKind::Artifact) {
85 return BTreeMap::new();
86 }
87 let artifact_path = PathBuf::from(&opened.source_path);
88 let mut refs: Vec<&str> = Vec::new();
89 for lock in &opened.lock.app_packs {
90 refs.push(lock.reference.as_str());
91 }
92 for lock in &opened.lock.extension_providers {
93 refs.push(lock.reference.as_str());
94 }
95 pack_probe::probe_inlined_packs(&artifact_path, &refs)
96}
97
98fn project(
99 manifest: &BundleManifest,
100 lock: &BundleLock,
101 pack_meta: &BTreeMap<String, PackMetaSlim>,
102) -> InfoReport {
103 InfoReport {
104 info_schema_version: 1,
105 bundle_id: manifest.bundle_id.clone(),
106 name: manifest.bundle_name.clone(),
107 version: None,
108 description: None,
109 mode: manifest.requested_mode.clone(),
110 locale: manifest.locale.clone(),
111 app_packs: project_packs(&manifest.app_packs, &lock.app_packs, pack_meta),
112 extension_providers: project_packs(
113 &manifest.extension_providers,
114 &lock.extension_providers,
115 pack_meta,
116 ),
117 catalogs: lock
118 .catalogs
119 .iter()
120 .map(|c| CatalogRef {
121 name: catalog_display_name(&c.resolved_ref, &c.requested_ref),
122 item_count: c.item_count as u32,
123 })
124 .collect(),
125 access: access_summary(&manifest.resolved_targets),
126 capabilities: manifest.capabilities.clone(),
127 hooks: manifest.hooks.clone(),
128 subscriptions: manifest.subscriptions.clone(),
129 }
130}
131
132fn project_packs(
133 names: &[String],
134 locks: &[DependencyLock],
135 pack_meta: &BTreeMap<String, PackMetaSlim>,
136) -> Vec<PackRef> {
137 names
138 .iter()
139 .map(|name| {
140 let digest = locks
141 .iter()
142 .find(|l| l.reference == *name)
143 .and_then(|l| l.digest.clone());
144 let version = pack_meta.get(name).and_then(|m| m.version.clone());
147 PackRef {
148 reference: name.clone(),
149 version,
150 digest,
151 }
152 })
153 .collect()
154}
155
156fn catalog_display_name(resolved: &str, requested: &str) -> String {
157 if !resolved.is_empty() {
158 resolved.to_string()
159 } else {
160 requested.to_string()
161 }
162}
163
164fn access_summary(targets: &[BundleResolvedTargetView]) -> AccessSummary {
165 use std::collections::BTreeMap;
166 let mut per_tenant: BTreeMap<String, (u32, Option<String>)> = BTreeMap::new();
167 for t in targets {
168 let entry = per_tenant.entry(t.tenant.clone()).or_insert((0, None));
169 if t.team.is_some() {
170 entry.0 += 1;
171 }
172 if entry.1.is_none() {
173 entry.1 = Some(t.default_policy.clone());
174 }
175 }
176 let teams: u32 = per_tenant.values().map(|(c, _)| *c).sum();
177 AccessSummary {
178 tenants: per_tenant.len() as u32,
179 teams,
180 targets: per_tenant
181 .into_iter()
182 .map(|(tenant, (team_count, pol))| AccessTarget {
183 tenant,
184 team_count,
185 default_policy: pol.unwrap_or_else(|| "public".into()),
186 })
187 .collect(),
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn json_has_schema_version_one() {
197 let report = InfoReport {
198 info_schema_version: 1,
199 bundle_id: "b".into(),
200 name: "b".into(),
201 version: None,
202 description: None,
203 mode: "production".into(),
204 locale: "en".into(),
205 app_packs: vec![],
206 extension_providers: vec![],
207 catalogs: vec![],
208 access: AccessSummary {
209 tenants: 0,
210 teams: 0,
211 targets: vec![],
212 },
213 capabilities: vec![],
214 hooks: vec![],
215 subscriptions: vec![],
216 };
217 let v: serde_json::Value = serde_json::to_value(&report).unwrap();
218 assert_eq!(v["info_schema_version"], 1);
219 assert_eq!(v["mode"], "production");
220 assert_eq!(v["locale"], "en");
221 assert_eq!(v["access"]["tenants"], 0);
222 }
223
224 #[test]
225 fn from_opened_bundle_projects_all_fields() {
226 use greentic_bundle_reader::{
227 BundleLock, BundleManifest, BundleResolvedTargetView, BundleSourceKind,
228 CatalogLockEntry, DependencyLock, OpenedBundle,
229 };
230
231 let manifest = BundleManifest {
232 format_version: "1".into(),
233 bundle_id: "acme".into(),
234 bundle_name: "acme-demo".into(),
235 requested_mode: "production".into(),
236 locale: "en".into(),
237 artifact_extension: "gtbundle".into(),
238 generated_resolved_files: vec![],
239 generated_setup_files: vec![],
240 app_packs: vec!["hello-bot".into(), "support-bot".into()],
241 extension_providers: vec!["slack-provider".into()],
242 catalogs: vec![],
243 hooks: vec!["on_install".into()],
244 subscriptions: vec!["user.created".into()],
245 capabilities: vec!["state.kv".into()],
246 resolved_targets: vec![
247 BundleResolvedTargetView {
248 path: "tenants/default/default.yaml".into(),
249 tenant: "default".into(),
250 team: Some("engineering".into()),
251 default_policy: "public".into(),
252 tenant_gmap: "tenants/default/tenant.gmap".into(),
253 team_gmap: Some("tenants/default/engineering.gmap".into()),
254 app_pack_policies: vec![],
255 },
256 BundleResolvedTargetView {
257 path: "tenants/default/marketing.yaml".into(),
258 tenant: "default".into(),
259 team: Some("marketing".into()),
260 default_policy: "public".into(),
261 tenant_gmap: "tenants/default/tenant.gmap".into(),
262 team_gmap: Some("tenants/default/marketing.gmap".into()),
263 app_pack_policies: vec![],
264 },
265 BundleResolvedTargetView {
266 path: "tenants/acme/tenant.yaml".into(),
267 tenant: "acme".into(),
268 team: None,
269 default_policy: "forbidden".into(),
270 tenant_gmap: "tenants/acme/tenant.gmap".into(),
271 team_gmap: None,
272 app_pack_policies: vec![],
273 },
274 ],
275 };
276
277 let lock = BundleLock {
278 schema_version: 1,
279 bundle_id: "acme".into(),
280 requested_mode: "production".into(),
281 execution: "default".into(),
282 cache_policy: "default".into(),
283 tool_version: "0.0.0".into(),
284 build_format_version: "1".into(),
285 workspace_root: "".into(),
286 lock_file: "".into(),
287 catalogs: vec![CatalogLockEntry {
288 requested_ref: "file://catalog.json".into(),
289 resolved_ref: "catalog.json".into(),
290 digest: "sha256:abc".into(),
291 source: "file".into(),
292 item_count: 12,
293 item_ids: vec!["hello-bot".into()],
294 cache_path: None,
295 }],
296 app_packs: vec![
297 DependencyLock {
298 reference: "hello-bot".into(),
299 digest: Some("sha256:aaa".into()),
300 },
301 DependencyLock {
302 reference: "support-bot".into(),
303 digest: None,
304 },
305 ],
306 extension_providers: vec![DependencyLock {
307 reference: "slack-provider".into(),
308 digest: Some("sha256:bbb".into()),
309 }],
310 setup_state_files: vec![],
311 };
312
313 let opened = OpenedBundle {
314 source_kind: BundleSourceKind::Artifact,
315 source_path: "/tmp/demo.gtbundle".into(),
316 format_version: "1".into(),
317 manifest,
318 lock,
319 };
320
321 let r = InfoReport::from_opened_bundle(&opened);
322
323 assert_eq!(r.info_schema_version, 1);
324 assert_eq!(r.bundle_id, "acme");
325 assert_eq!(r.name, "acme-demo");
326 assert_eq!(r.version, None);
327 assert_eq!(r.description, None);
328 assert_eq!(r.mode, "production");
329 assert_eq!(r.locale, "en");
330
331 assert_eq!(r.app_packs.len(), 2);
332 assert_eq!(r.app_packs[0].reference, "hello-bot");
333 assert_eq!(r.app_packs[0].digest.as_deref(), Some("sha256:aaa"));
334 assert_eq!(r.app_packs[1].reference, "support-bot");
335 assert_eq!(r.app_packs[1].digest, None);
336
337 assert_eq!(r.extension_providers.len(), 1);
338 assert_eq!(r.extension_providers[0].reference, "slack-provider");
339 assert_eq!(
340 r.extension_providers[0].digest.as_deref(),
341 Some("sha256:bbb")
342 );
343
344 assert_eq!(r.catalogs.len(), 1);
345 assert_eq!(r.catalogs[0].name, "catalog.json");
346 assert_eq!(r.catalogs[0].item_count, 12);
347
348 assert_eq!(r.access.tenants, 2);
349 assert_eq!(r.access.teams, 2);
350 let default_target = r
351 .access
352 .targets
353 .iter()
354 .find(|t| t.tenant == "default")
355 .unwrap();
356 assert_eq!(default_target.team_count, 2);
357 assert_eq!(default_target.default_policy, "public");
358 let acme_target = r
359 .access
360 .targets
361 .iter()
362 .find(|t| t.tenant == "acme")
363 .unwrap();
364 assert_eq!(acme_target.team_count, 0);
365 assert_eq!(acme_target.default_policy, "forbidden");
366
367 assert_eq!(r.capabilities, vec!["state.kv".to_string()]);
368 assert_eq!(r.hooks, vec!["on_install".to_string()]);
369 assert_eq!(r.subscriptions, vec!["user.created".to_string()]);
370 }
371}