1use std::collections::{BTreeMap, BTreeSet};
2use std::io::Read;
3use std::path::{Path, PathBuf};
4
5use anyhow::Context;
6use serde_json::Value as JsonValue;
7use zip::ZipArchive;
8use zip::result::ZipError;
9
10pub const HOOK_STAGE_POST_INGRESS: &str = "post_ingress";
11pub const HOOK_CONTRACT_CONTROL_V1: &str = "greentic.hook.control.v1";
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
14pub enum OfferKind {
15 Hook,
16 Subs,
17 Capability,
18}
19
20impl OfferKind {
21 fn parse(raw: &str) -> Option<Self> {
22 match raw.trim().to_ascii_lowercase().as_str() {
23 "hook" => Some(Self::Hook),
24 "subs" => Some(Self::Subs),
25 "capability" => Some(Self::Capability),
26 _ => None,
27 }
28 }
29
30 pub fn as_str(&self) -> &'static str {
31 match self {
32 Self::Hook => "hook",
33 Self::Subs => "subs",
34 Self::Capability => "capability",
35 }
36 }
37}
38
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct Offer {
41 pub offer_key: String,
42 pub pack_id: String,
43 pub pack_ref: PathBuf,
44 pub id: String,
45 pub kind: OfferKind,
46 pub priority: i32,
47 pub provider_op: String,
48 pub stage: Option<String>,
49 pub contract: Option<String>,
50}
51
52#[derive(Clone, Debug)]
53pub struct PackOffers {
54 pub pack_id: String,
55 pub pack_ref: PathBuf,
56 pub offers: Vec<PackOffer>,
57}
58
59#[derive(Clone, Debug)]
60pub struct PackOffer {
61 pub id: String,
62 pub kind: OfferKind,
63 pub priority: i32,
64 pub provider_op: String,
65 pub stage: Option<String>,
66 pub contract: Option<String>,
67}
68
69#[derive(Clone, Debug, Default)]
70pub struct OfferRegistry {
71 by_key: BTreeMap<String, Offer>,
72 pack_refs: BTreeMap<String, PathBuf>,
73}
74
75impl OfferRegistry {
76 pub fn from_pack_refs(pack_refs: &[PathBuf]) -> anyhow::Result<Self> {
77 let mut registry = Self::default();
78 for pack_ref in pack_refs {
79 let parsed = load_pack_offers(pack_ref)?;
80 registry.register_pack(parsed)?;
81 }
82 Ok(registry)
83 }
84
85 pub fn register_pack(&mut self, pack: PackOffers) -> anyhow::Result<()> {
86 if let Some(existing_ref) = self.pack_refs.get(&pack.pack_id)
87 && existing_ref != &pack.pack_ref
88 {
89 anyhow::bail!(
90 "duplicate pack_id {} across packs: {} and {}",
91 pack.pack_id,
92 existing_ref.display(),
93 pack.pack_ref.display()
94 );
95 }
96 self.pack_refs
97 .entry(pack.pack_id.clone())
98 .or_insert_with(|| pack.pack_ref.clone());
99
100 for offer in pack.offers {
101 let offer_key = offer_key(&pack.pack_id, &offer.id);
102 let record = Offer {
103 offer_key: offer_key.clone(),
104 pack_id: pack.pack_id.clone(),
105 pack_ref: pack.pack_ref.clone(),
106 id: offer.id,
107 kind: offer.kind,
108 priority: offer.priority,
109 provider_op: offer.provider_op,
110 stage: offer.stage,
111 contract: offer.contract,
112 };
113 self.by_key.insert(offer_key, record);
114 }
115 Ok(())
116 }
117
118 pub fn offers_total(&self) -> usize {
119 self.by_key.len()
120 }
121
122 pub fn packs_total(&self) -> usize {
123 self.pack_refs.len()
124 }
125
126 pub fn kind_counts(&self) -> BTreeMap<&'static str, usize> {
127 let mut counts = BTreeMap::new();
128 for offer in self.by_key.values() {
129 *counts.entry(offer.kind.as_str()).or_insert(0) += 1;
130 }
131 counts
132 }
133
134 pub fn hook_counts_by_stage_contract(&self) -> Vec<(String, String, usize)> {
135 let mut counts: BTreeMap<(String, String), usize> = BTreeMap::new();
136 for offer in self.by_key.values() {
137 if offer.kind != OfferKind::Hook {
138 continue;
139 }
140 let Some(stage) = offer.stage.clone() else {
141 continue;
142 };
143 let Some(contract) = offer.contract.clone() else {
144 continue;
145 };
146 *counts.entry((stage, contract)).or_insert(0) += 1;
147 }
148 counts
149 .into_iter()
150 .map(|((stage, contract), count)| (stage, contract, count))
151 .collect()
152 }
153
154 pub fn subs_counts_by_contract(&self) -> Vec<(String, usize)> {
155 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
156 for offer in self.by_key.values() {
157 if offer.kind != OfferKind::Subs {
158 continue;
159 }
160 let contract = offer
161 .contract
162 .clone()
163 .unwrap_or_else(|| "<none>".to_string());
164 *counts.entry(contract).or_insert(0) += 1;
165 }
166 counts.into_iter().collect()
167 }
168
169 pub fn select_hooks(&self, stage: &str, contract: &str) -> Vec<&Offer> {
170 let mut selected = self
171 .by_key
172 .values()
173 .filter(|offer| {
174 offer.kind == OfferKind::Hook
175 && offer.stage.as_deref() == Some(stage)
176 && offer.contract.as_deref() == Some(contract)
177 })
178 .collect::<Vec<_>>();
179 selected.sort_by(|a, b| {
180 a.priority
181 .cmp(&b.priority)
182 .then_with(|| a.offer_key.cmp(&b.offer_key))
183 });
184 selected
185 }
186
187 pub fn select_subs(&self, contract: Option<&str>) -> Vec<&Offer> {
188 let mut selected = self
189 .by_key
190 .values()
191 .filter(|offer| {
192 offer.kind == OfferKind::Subs
193 && contract
194 .map(|expected| offer.contract.as_deref() == Some(expected))
195 .unwrap_or(true)
196 })
197 .collect::<Vec<_>>();
198 selected.sort_by(|a, b| {
199 a.priority
200 .cmp(&b.priority)
201 .then_with(|| a.offer_key.cmp(&b.offer_key))
202 });
203 selected
204 }
205}
206
207pub fn offer_key(pack_id: &str, offer_id: &str) -> String {
208 format!("{pack_id}::{offer_id}")
209}
210
211pub fn discover_gtpacks(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
212 let mut files = Vec::new();
213 let mut stack = vec![root.to_path_buf()];
214 while let Some(dir) = stack.pop() {
215 for entry in std::fs::read_dir(&dir)? {
216 let entry = entry?;
217 let path = entry.path();
218 if entry.file_type()?.is_dir() {
219 stack.push(path);
220 continue;
221 }
222 if path.extension().and_then(|ext| ext.to_str()) == Some("gtpack") {
223 files.push(path);
224 }
225 }
226 }
227 files.sort();
228 Ok(files)
229}
230
231pub fn load_pack_offers(pack_ref: &Path) -> anyhow::Result<PackOffers> {
232 let file = std::fs::File::open(pack_ref)?;
233 let mut archive = ZipArchive::new(file)?;
234 let mut manifest_entry = archive.by_name("manifest.cbor").map_err(|err| match err {
235 ZipError::FileNotFound => {
236 anyhow::anyhow!("manifest.cbor missing in {}", pack_ref.display())
237 }
238 other => anyhow::anyhow!(
239 "failed to read manifest.cbor in {}: {other}",
240 pack_ref.display()
241 ),
242 })?;
243 let mut bytes = Vec::new();
244 manifest_entry.read_to_end(&mut bytes)?;
245 let manifest: JsonValue = serde_cbor::from_slice(&bytes)
246 .with_context(|| format!("decode manifest.cbor {}", pack_ref.display()))?;
247
248 let pack_id = manifest_pack_id(&manifest).ok_or_else(|| {
249 anyhow::anyhow!("pack manifest missing pack id in {}", pack_ref.display())
250 })?;
251 let offers = parse_pack_offers(&manifest, pack_ref)?;
252 Ok(PackOffers {
253 pack_id,
254 pack_ref: pack_ref.to_path_buf(),
255 offers,
256 })
257}
258
259fn parse_pack_offers(manifest: &JsonValue, pack_ref: &Path) -> anyhow::Result<Vec<PackOffer>> {
260 let mut offers_raw: Vec<&JsonValue> = Vec::new();
261 if let Some(array) = manifest.get("offers").and_then(JsonValue::as_array) {
262 offers_raw.extend(array);
263 }
264 if let Some(extensions) = manifest.get("extensions").and_then(JsonValue::as_object) {
265 for ext in extensions.values() {
266 if let Some(array) = ext.get("offers").and_then(JsonValue::as_array) {
267 offers_raw.extend(array);
268 }
269 if let Some(array) = ext
270 .get("inline")
271 .and_then(|value| value.get("offers"))
272 .and_then(JsonValue::as_array)
273 {
274 offers_raw.extend(array);
275 }
276 }
277 }
278
279 let mut parsed = Vec::new();
280 let mut seen_ids = BTreeSet::new();
281 for raw in offers_raw {
282 let Some(raw_obj) = raw.as_object() else {
283 continue;
284 };
285 let candidate = raw_obj.contains_key("id")
286 || raw_obj.contains_key("offer_id")
287 || raw_obj.contains_key("kind")
288 || raw_obj.contains_key("cap_id");
289 if !candidate {
290 continue;
291 }
292
293 let id = raw_obj
294 .get("id")
295 .or_else(|| raw_obj.get("offer_id"))
296 .and_then(JsonValue::as_str)
297 .map(str::trim)
298 .filter(|value| !value.is_empty())
299 .ok_or_else(|| anyhow::anyhow!("offer id missing in {}", pack_ref.display()))?
300 .to_string();
301 if !seen_ids.insert(id.clone()) {
302 anyhow::bail!("duplicate offer id {} in {}", id, pack_ref.display());
303 }
304
305 let kind = raw_obj
306 .get("kind")
307 .and_then(JsonValue::as_str)
308 .and_then(OfferKind::parse)
309 .or_else(|| {
310 if raw_obj.contains_key("cap_id") {
311 Some(OfferKind::Capability)
312 } else {
313 None
314 }
315 })
316 .ok_or_else(|| {
317 anyhow::anyhow!(
318 "offer kind missing/invalid for {} in {}",
319 id,
320 pack_ref.display()
321 )
322 })?;
323
324 let provider_op = raw_obj
325 .get("provider")
326 .and_then(|value| value.get("op"))
327 .and_then(JsonValue::as_str)
328 .map(str::trim)
329 .filter(|value| !value.is_empty())
330 .ok_or_else(|| {
331 anyhow::anyhow!(
332 "provider.op missing for offer {} in {}",
333 id,
334 pack_ref.display()
335 )
336 })?
337 .to_string();
338
339 let priority = raw_obj
340 .get("priority")
341 .and_then(JsonValue::as_i64)
342 .map(|value| value as i32)
343 .unwrap_or(100);
344
345 let stage = raw_obj
346 .get("stage")
347 .and_then(JsonValue::as_str)
348 .map(str::trim)
349 .filter(|value| !value.is_empty())
350 .map(ToString::to_string);
351 let contract = raw_obj
352 .get("contract")
353 .and_then(JsonValue::as_str)
354 .map(str::trim)
355 .filter(|value| !value.is_empty())
356 .map(ToString::to_string);
357
358 parsed.push(PackOffer {
359 id,
360 kind,
361 priority,
362 provider_op,
363 stage,
364 contract,
365 });
366 }
367
368 Ok(parsed)
369}
370
371fn manifest_pack_id(manifest: &JsonValue) -> Option<String> {
372 manifest
373 .get("meta")
374 .and_then(|value| value.get("pack_id"))
375 .and_then(JsonValue::as_str)
376 .or_else(|| manifest.get("pack_id").and_then(JsonValue::as_str))
377 .map(str::trim)
378 .filter(|value| !value.is_empty())
379 .map(ToString::to_string)
380 .or_else(|| resolve_symbol_pack_id(manifest))
381}
382
383fn resolve_symbol_pack_id(manifest: &JsonValue) -> Option<String> {
386 let idx = manifest
387 .get("meta")
388 .and_then(|m| m.get("pack_id"))
389 .or_else(|| manifest.get("pack_id"))
390 .and_then(JsonValue::as_u64)? as usize;
391 manifest
392 .get("symbols")
393 .and_then(|s| s.get("pack_ids"))
394 .and_then(JsonValue::as_array)
395 .and_then(|arr| arr.get(idx))
396 .and_then(JsonValue::as_str)
397 .map(ToString::to_string)
398}
399
400#[cfg(test)]
401mod tests {
402 use std::io::Write;
403
404 use serde_json::json;
405 use tempfile::tempdir;
406 use zip::write::FileOptions;
407
408 use super::*;
409
410 #[test]
411 fn duplicate_offer_ids_within_pack_fail() {
412 let tmp = tempdir().expect("tempdir");
413 let pack_path = tmp.path().join("dup.gtpack");
414 write_manifest_pack(
415 &pack_path,
416 &json!({
417 "meta": { "pack_id": "pack-a" },
418 "extensions": {
419 "greentic.ext.offers.v1": {
420 "inline": {
421 "offers": [
422 { "id": "x", "kind": "hook", "provider": { "op": "hook_a" } },
423 { "id": "x", "kind": "hook", "provider": { "op": "hook_b" } }
424 ]
425 }
426 }
427 }
428 }),
429 );
430
431 let err = load_pack_offers(&pack_path).unwrap_err().to_string();
432 assert!(err.contains("duplicate offer id"));
433 }
434
435 #[test]
436 fn duplicate_pack_id_across_packs_fail() {
437 let tmp = tempdir().expect("tempdir");
438 let pack_a = tmp.path().join("a.gtpack");
439 let pack_b = tmp.path().join("b.gtpack");
440 write_manifest_pack(
441 &pack_a,
442 &json!({
443 "meta": { "pack_id": "pack-a" },
444 "offers": [
445 { "id": "one", "kind": "capability", "provider": { "op": "op_a" } }
446 ]
447 }),
448 );
449 write_manifest_pack(
450 &pack_b,
451 &json!({
452 "meta": { "pack_id": "pack-a" },
453 "offers": [
454 { "id": "two", "kind": "capability", "provider": { "op": "op_b" } }
455 ]
456 }),
457 );
458
459 let err = OfferRegistry::from_pack_refs(&[pack_a, pack_b])
460 .unwrap_err()
461 .to_string();
462 assert!(err.contains("duplicate pack_id"));
463 }
464
465 #[test]
466 fn hook_selection_is_priority_then_offer_key() {
467 let mut registry = OfferRegistry::default();
468 registry
469 .register_pack(PackOffers {
470 pack_id: "pack-b".to_string(),
471 pack_ref: PathBuf::from("/tmp/pack-b.gtpack"),
472 offers: vec![PackOffer {
473 id: "offer-b".to_string(),
474 kind: OfferKind::Hook,
475 priority: 100,
476 provider_op: "hook_b".to_string(),
477 stage: Some(HOOK_STAGE_POST_INGRESS.to_string()),
478 contract: Some(HOOK_CONTRACT_CONTROL_V1.to_string()),
479 }],
480 })
481 .expect("register b");
482 registry
483 .register_pack(PackOffers {
484 pack_id: "pack-a".to_string(),
485 pack_ref: PathBuf::from("/tmp/pack-a.gtpack"),
486 offers: vec![
487 PackOffer {
488 id: "offer-a".to_string(),
489 kind: OfferKind::Hook,
490 priority: 100,
491 provider_op: "hook_a".to_string(),
492 stage: Some(HOOK_STAGE_POST_INGRESS.to_string()),
493 contract: Some(HOOK_CONTRACT_CONTROL_V1.to_string()),
494 },
495 PackOffer {
496 id: "offer-c".to_string(),
497 kind: OfferKind::Hook,
498 priority: 10,
499 provider_op: "hook_c".to_string(),
500 stage: Some(HOOK_STAGE_POST_INGRESS.to_string()),
501 contract: Some(HOOK_CONTRACT_CONTROL_V1.to_string()),
502 },
503 ],
504 })
505 .expect("register a");
506
507 let selected = registry.select_hooks(HOOK_STAGE_POST_INGRESS, HOOK_CONTRACT_CONTROL_V1);
508 let keys = selected
509 .iter()
510 .map(|offer| offer.offer_key.clone())
511 .collect::<Vec<_>>();
512 assert_eq!(
513 keys,
514 vec![
515 "pack-a::offer-c".to_string(),
516 "pack-a::offer-a".to_string(),
517 "pack-b::offer-b".to_string()
518 ]
519 );
520 }
521
522 #[test]
523 fn subs_selection_filters_and_sorts() {
524 let mut registry = OfferRegistry::default();
525 registry
526 .register_pack(PackOffers {
527 pack_id: "pack-s".to_string(),
528 pack_ref: PathBuf::from("/tmp/pack-s.gtpack"),
529 offers: vec![
530 PackOffer {
531 id: "subs-a".to_string(),
532 kind: OfferKind::Subs,
533 priority: 100,
534 provider_op: "subs_a".to_string(),
535 stage: Some("post_ingress".to_string()),
536 contract: Some("contract-a".to_string()),
537 },
538 PackOffer {
539 id: "subs-b".to_string(),
540 kind: OfferKind::Subs,
541 priority: 10,
542 provider_op: "subs_b".to_string(),
543 stage: Some("post_ingress".to_string()),
544 contract: Some("contract-b".to_string()),
545 },
546 ],
547 })
548 .expect("register subs");
549
550 let filtered = registry.select_subs(Some("contract-a"));
551 assert_eq!(filtered.len(), 1);
552 assert_eq!(filtered[0].offer_key, "pack-s::subs-a");
553
554 let all = registry.select_subs(None);
555 let keys = all
556 .iter()
557 .map(|offer| offer.offer_key.clone())
558 .collect::<Vec<_>>();
559 assert_eq!(
560 keys,
561 vec!["pack-s::subs-b".to_string(), "pack-s::subs-a".to_string()]
562 );
563 }
564
565 fn write_manifest_pack(path: &Path, manifest: &JsonValue) {
566 let file = std::fs::File::create(path).expect("create gtpack");
567 let mut zip = zip::ZipWriter::new(file);
568 zip.start_file("manifest.cbor", FileOptions::<()>::default())
569 .expect("start manifest");
570 let bytes = serde_cbor::to_vec(manifest).expect("manifest cbor");
571 zip.write_all(&bytes).expect("write manifest");
572 zip.finish().expect("finish zip");
573 }
574}