1use crate::{Error, Result};
16
17use crate::SliceSet;
18use crate::report::{
19 CarrierRow, InterfaceList, InterfaceShow, InterfaceTypeRow, ServiceInfo, ServiceList,
20 ServiceProcedure, ServiceRow, TopicInfo, TopicList, TopicRow,
21};
22use zenkey::{Class, Declared};
23
24impl SliceSet {
25 pub fn topic_list(
39 &self,
40 producer: Option<&str>,
41 class: Option<Class>,
42 type_name: Option<&str>,
43 deprecated: bool,
44 ) -> Result<TopicList> {
45 let slices = self.slices();
46 let mut subjects = Vec::new();
47 for slice in slices {
48 if producer.is_some_and(|p| p != slice.name) {
49 continue;
50 }
51 for s in slice
52 .subjects
53 .iter()
54 .filter(|s| class.is_none_or(|c| s.class.is(&c)))
55 .filter(|s| type_name.is_none_or(|t| t == s.type_name))
56 {
57 subjects.push(TopicRow {
58 producer: slice.name.clone(),
59 registry_version: slice.version.clone(),
60 class: s.class.token().to_string(),
61 path: s.path.clone(),
62 type_name: s.type_name.clone(),
63 open_ended: s.path.contains("..."),
64 since: s.since.clone(),
65 deprecated: false,
66 deprecated_since: None,
67 replaced_by: None,
68 cardinality: s.cardinality,
69 budget: None,
70 });
71 }
72 if deprecated && type_name.is_none() && class.is_none() {
77 for d in &slice.deprecated {
78 subjects.push(TopicRow {
79 producer: slice.name.clone(),
80 registry_version: slice.version.clone(),
81 class: "-".into(),
82 path: d.path.clone(),
83 type_name: String::new(),
84 open_ended: false,
85 since: None,
86 deprecated: true,
87 deprecated_since: d.since.clone(),
88 replaced_by: d.replaced_by.clone(),
89 cardinality: None,
90 budget: None,
91 });
92 }
93 }
94 }
95 Ok(TopicList {
96 subjects,
97 budget: None,
98 })
99 }
100
101 pub fn topic_info(&self, base: &str, key: &str) -> TopicInfo {
108 TopicInfo::from_description(&crate::describe_key(base, key, Some(self)))
114 }
115
116 pub fn service_list(&self, producer: Option<&str>) -> ServiceList {
117 let slices = self.slices();
118 let mut procedures = Vec::new();
119 for slice in slices {
120 if producer.is_some_and(|p| p != slice.name) {
121 continue;
122 }
123 for p in &slice.procedures {
124 procedures.push(ServiceRow {
125 producer: slice.name.clone(),
126 registry_version: slice.version.clone(),
127 kind: p
128 .kind
129 .as_ref()
130 .map(Declared::token)
131 .unwrap_or_default()
132 .to_string(),
133 path: p.path.clone(),
134 request: p.request.clone(),
135 reply: p.reply.clone(),
136 });
137 }
138 }
139 ServiceList { procedures }
140 }
141
142 pub fn service_info(&self, producer: &str, path: Option<&str>) -> Result<ServiceInfo> {
149 let Some(slice) = self.get(producer) else {
150 let mut known: Vec<&str> = self.slices().iter().map(|s| s.name.as_str()).collect();
151 known.sort_unstable();
152 return Err(Error::unaskable(
154 format!("producer {producer:?}"),
155 format!(
156 "no slice declares it.\nknown producers: {}",
157 known.join(", ")
158 ),
159 ));
160 };
161 let origin = slice
162 .service_origin
163 .as_ref()
164 .map(Declared::token)
165 .unwrap_or("{origin}");
166 let procedures = slice
167 .procedures
168 .iter()
169 .filter(|p| path.is_none_or(|want| want == p.path))
170 .map(|p| ServiceProcedure {
171 key: match &slice.service_origin {
173 Some(_) => format!("v1/{origin}/@rpc/{}", p.path),
174 None => format!("v1/{origin}/@rpc/{}/{}", slice.name, p.path),
175 },
176 path: p.path.clone(),
177 kind: p
178 .kind
179 .as_ref()
180 .map(Declared::token)
181 .unwrap_or_default()
182 .to_string(),
183 request: p.request.clone(),
184 reply: p.reply.clone(),
185 fanout: p.fanout.as_ref().map(|f| f.token().to_string()),
186 idempotent: p.idempotent,
187 encoding: p.encoding.as_ref().map(|e| e.as_encoding_str().to_string()),
188 since: p.since.clone(),
189 description: p.description.clone(),
190 })
191 .collect::<Vec<_>>();
192 if let Some(want) = path
193 && procedures.is_empty()
194 {
195 let mut known: Vec<&str> = slice.procedures.iter().map(|p| p.path.as_str()).collect();
196 known.sort_unstable();
197 return Err(Error::unaskable(
198 format!("procedure {want:?}"),
199 format!(
200 "{producer} declares no such procedure.\nit declares: {}",
201 known.join(", ")
202 ),
203 ));
204 }
205 Ok(ServiceInfo {
206 producer: slice.name.clone(),
207 registry_version: slice.version.clone(),
208 service_origin: slice.service_origin.as_ref().map(|o| o.token().to_string()),
209 description: slice.description.clone(),
210 procedures,
211 })
212 }
213
214 pub fn interface_list(&self) -> InterfaceList {
219 let slices = self.slices();
220 use std::collections::BTreeMap;
221 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
222 for slice in slices {
223 for s in &slice.subjects {
224 if !s.type_name.is_empty() {
225 *counts.entry(s.type_name.as_str()).or_default() += 1;
226 }
227 }
228 for p in &slice.procedures {
229 if let Some(r) = &p.reply {
230 *counts.entry(r.as_str()).or_default() += 1;
231 }
232 }
233 for b in &slice.blob {
236 if let Some(r) = &b.reference {
237 *counts.entry(r.as_str()).or_default() += 1;
238 }
239 }
240 }
241 InterfaceList {
242 types: counts
243 .into_iter()
244 .map(|(name, carriers)| InterfaceTypeRow {
245 name: name.to_string(),
246 carriers,
247 })
248 .collect(),
249 }
250 }
251
252 pub fn interface_show(&self, type_name: &str) -> Result<InterfaceShow> {
255 let slices = self.slices();
256 let mut carriers: Vec<CarrierRow> = Vec::new();
257 for slice in slices {
258 for s in &slice.subjects {
259 if s.type_name == type_name {
260 carriers.push(CarrierRow {
261 producer: slice.name.clone(),
262 class: s.class.token().to_string(),
263 path: s.path.clone(),
264 });
265 }
266 }
267 for b in &slice.blob {
271 if b.reference.as_deref() == Some(type_name) {
272 carriers.push(CarrierRow {
273 producer: slice.name.clone(),
274 class: "@blob".to_string(),
275 path: b.tier.token().to_string(),
276 });
277 }
278 }
279 for p in &slice.procedures {
280 if p.reply.as_deref() == Some(type_name) {
281 carriers.push(CarrierRow {
282 producer: slice.name.clone(),
283 class: "@rpc".to_string(),
284 path: p.path.clone(),
285 });
286 }
287 }
288 }
289
290 if carriers.is_empty() {
291 let mut known: Vec<&str> = slices
292 .iter()
293 .flat_map(|s| s.subjects.iter().map(|s| s.type_name.as_str()))
294 .filter(|t| !t.is_empty())
295 .collect();
296 known.sort();
297 known.dedup();
298 return Err(Error::unaskable(
299 format!("type {type_name:?}"),
300 format!(
301 "no registered subject carries it.\nknown types: {}",
302 known.join(", ")
303 ),
304 ));
305 }
306
307 Ok(InterfaceShow {
308 type_name: type_name.to_string(),
309 carriers,
310 schemas: crate::report::Asked::NotAsked,
314 })
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use zenkey::{RegistrySlice, parse_slice};
322
323 fn set(slices: &[zenkey::RegistrySlice]) -> SliceSet {
325 SliceSet::from_slices(slices.to_vec())
326 }
327
328 const TCGUI_SLICE: &str = r#"
335 [registry]
336 version = "0.3"
337 app = "tcgui"
338 convention = 1
339
340 [producer]
341 name = "tc"
342 description = "traffic-control netem shaper"
343
344 [[subject]]
345 path = "iface/{iface}/state"
346 class = "state"
347 type = "NetworkInterface"
348 fanout = "per-iface"
349 since = "0.1"
350 ttl_s = 30
351 qos = "refreshed"
352 description = "current netem config on an interface"
353
354 [[subject]]
355 path = "health"
356 class = "state"
357 type = "BackendHealthStatus"
358 since = "0.1"
359
360 [[procedure]]
361 path = "iface/{iface}/set"
362 kind = "write"
363 reply = "Ack"
364 fanout = "one"
365 since = "0.2"
366 description = "apply a netem config"
367
368 [[deprecated]]
369 path = "iface/{iface}/status"
370 since = "0.2"
371 replaced_by = "iface/{iface}/state"
372 "#;
373
374 fn tcgui_slices() -> Vec<RegistrySlice> {
375 vec![parse_slice(TCGUI_SLICE).unwrap()]
376 }
377
378 #[test]
382 fn type_and_deprecated_filters() {
383 let slices = tcgui_slices();
384
385 let by_type = set(&slices)
386 .topic_list(None, None, Some("NetworkInterface"), false)
387 .unwrap();
388 assert_eq!(by_type.subjects.len(), 1);
389 assert_eq!(by_type.subjects[0].path, "iface/{iface}/state");
390
391 let without = set(&slices).topic_list(None, None, None, false).unwrap();
392 assert!(without.subjects.iter().all(|s| !s.deprecated));
393
394 let with = set(&slices).topic_list(None, None, None, true).unwrap();
395 let retired: Vec<_> = with.subjects.iter().filter(|s| s.deprecated).collect();
396 assert_eq!(retired.len(), 1);
397 assert_eq!(retired[0].path, "iface/{iface}/status");
398 assert_eq!(retired[0].deprecated_since.as_deref(), Some("0.2"));
399 assert_eq!(
400 retired[0].replaced_by.as_deref(),
401 Some("iface/{iface}/state")
402 );
403 assert_eq!(with.subjects[0].since.as_deref(), Some("0.1"));
405 }
406
407 #[test]
408 fn foreign_tcgui_slice_parses_and_renders() {
409 let slice = parse_slice(TCGUI_SLICE).unwrap();
411 assert_eq!(slice.name, "tc");
412 assert_eq!(slice.app, "tcgui");
413 assert_eq!(slice.subjects.len(), 2);
414 assert_eq!(slice.procedures.len(), 1);
415 assert_eq!(slice.subjects[0].type_name, "NetworkInterface");
416 assert_eq!(slice.subjects[0].ttl_s, Some(30));
418 assert_eq!(
419 slice.subjects[0].qos.as_ref().and_then(Declared::known),
420 Some(&zenkey::QosProfile::Refreshed)
421 );
422 assert_eq!(
423 slice.procedures[0].kind.as_ref().and_then(Declared::known),
424 Some(&zenkey::ProcedureKind::Write)
425 );
426
427 let slices = tcgui_slices();
428
429 set(&slices).topic_list(None, None, None, false).unwrap();
432 set(&slices)
433 .topic_list(Some("tc"), Some(Class::State), None, false)
434 .unwrap();
435 set(&slices).service_list(Some("tc"));
436 set(&slices).interface_list();
437 set(&slices).interface_show("NetworkInterface").unwrap();
438
439 let info =
442 set(&slices).topic_info("tcgui", "tcgui/v1/h-3fa9c2d41b7e/state/tc/iface/eth0/state");
443 assert_eq!(info.verdict, crate::report::TopicVerdict::Registered);
444 }
445
446 #[test]
451 fn a_media_bearing_slice_is_readable_by_an_explorer() {
452 let src = format!(
453 "{}\n[[media]]\npath = \"{{stream}}/preview/jpeg\"\nencoding = \"image/jpeg\"\n\
454 attachment = \"FrameMeta\"\ncardinality = 16\nsince = \"1.0\"\n",
455 TCGUI_SLICE
456 );
457 let slice = parse_slice(&src).unwrap();
458 assert_eq!(slice.media.len(), 1);
459 assert_eq!(slice.media[0].path, "{stream}/preview/jpeg");
460 assert_eq!(slice.media[0].encoding.as_encoding_str(), "image/jpeg");
461 assert_eq!(slice.media[0].attachment.as_deref(), Some("FrameMeta"));
462
463 assert!(parse_slice(TCGUI_SLICE).unwrap().media.is_empty());
465 assert!(
467 parse_slice(&format!("{}\n[[media]]\npath = \"x\"\n", TCGUI_SLICE)).is_err(),
468 "encoding is required — the codec is declared, never sniffed"
469 );
470 }
471
472 #[test]
481 fn a_blob_bearing_slice_is_readable_by_an_explorer() {
482 let src = format!(
483 "{}\n[[blob]]\ntier = \"artifact\"\nendpoints = [\"manifest\", \"have\"]\n\
484 reference = \"Delivery\"\nsince = \"1.8\"\n\
485 [[blob]]\ntier = \"store\"\nalgo = \"blake3\"\nsince = \"1.8\"\n",
486 TCGUI_SLICE
487 );
488 let slice = parse_slice(&src).unwrap();
489 assert!(slice.serves_blob_tier(zenkey::BlobTier::Artifact));
490 assert!(slice.serves_blob_tier(zenkey::BlobTier::Store));
491 assert!(!slice.serves_blob_tier(zenkey::BlobTier::Tree));
492 assert_eq!(slice.blob[0].endpoints, ["manifest", "have"]);
493 assert_eq!(slice.blob[1].algo.as_deref(), Some("blake3"));
494
495 let slices = vec![slice];
498 let types = set(&slices).interface_list();
499 assert!(types.types.iter().any(|t| t.name == "Delivery"));
500 let show = set(&slices).interface_show("Delivery").unwrap();
501 assert!(
502 show.carriers
503 .iter()
504 .any(|c| c.class == "@blob" && c.path == "artifact"),
505 "{:?}",
506 show.carriers
507 );
508
509 let list = crate::blob_list(&slices, None, crate::report::BlobListSource::RegistryDirs);
512 assert_eq!(list.tiers.len(), 2);
513 assert_eq!(list.slices_considered, 1);
514 assert_eq!(list.slices_without_blob, 0);
515 assert!(list.tiers.iter().all(|t| t.known_tier));
516 assert!(list.tiers.iter().all(|t| t.origins.is_not_asked()));
518
519 let bare = parse_slice(TCGUI_SLICE).unwrap();
523 assert!(bare.blob.is_empty());
524 let none = crate::blob_list(&[bare], None, crate::report::BlobListSource::RegistryDirs);
525 assert!(none.tiers.is_empty());
526 assert_eq!(none.slices_considered, 1);
527 assert_eq!(none.slices_without_blob, 1);
528 }
529
530 #[test]
534 fn reports_serialize_to_stable_json() {
535 let slices = tcgui_slices();
536 let list = set(&slices)
537 .topic_list(Some("tc"), Some(Class::State), None, false)
538 .unwrap();
539 let json = serde_json::to_value(&list).unwrap();
540 assert_eq!(json["subjects"][0]["producer"], "tc");
541 assert_eq!(json["subjects"][0]["path"], "iface/{iface}/state");
542 assert_eq!(json["subjects"][0]["type_name"], "NetworkInterface");
543 assert_eq!(json["subjects"][0]["open_ended"], false);
544
545 let info =
546 set(&slices).topic_info("tcgui", "tcgui/v1/h-3fa9c2d41b7e/state/tc/iface/eth0/state");
547 let json = serde_json::to_value(&info).unwrap();
548 assert_eq!(json["verdict"], "registered");
549 assert_eq!(json["variables"]["iface"], "eth0");
550 assert_eq!(json["payload_type"], "NetworkInterface");
551 assert_eq!(json["ttl_s"], 30);
552
553 let services = set(&slices).service_list(None);
554 let json = serde_json::to_value(&services).unwrap();
555 assert_eq!(json["procedures"][0]["kind"], "write");
556 assert_eq!(json["procedures"][0]["reply"], "Ack");
557 }
558
559 #[test]
562 fn topic_info_describes_a_non_v1_key_instead_of_rejecting_it() {
563 use crate::report::TopicVerdict;
564 let info = set(&tcgui_slices()).topic_info("tcgui", "tcgui/tc/eth0/state");
565 assert_eq!(info.verdict, TopicVerdict::NotV1);
566 assert!(info.note.contains("fact, not an error"), "{}", info.note);
567 assert!(
568 info.payload_type.is_none(),
569 "nothing below the rung is invented"
570 );
571 }
572
573 #[test]
576 fn topic_info_reports_unregistered_subjects() {
577 use crate::report::TopicVerdict;
578 let info = set(&tcgui_slices()).topic_info(
579 "tcgui",
580 "tcgui/v1/h-3fa9c2d41b7e/state/tc/not_a_real_subject",
581 );
582 assert_eq!(info.verdict, TopicVerdict::Unregistered);
583 assert_eq!(info.producer.as_deref(), Some("tc"));
584 assert!(info.payload_type.is_none());
585 }
586
587 #[test]
591 fn an_unknown_class_is_rejected_at_the_parse_not_here() {
592 let err = "alerts".parse::<Class>().unwrap_err().to_string();
593 assert!(err.contains("unknown class"), "got: {err}");
594 assert!(err.contains("telemetry, state, events"), "got: {err}");
595 assert_eq!(Class::chunks().len(), Class::ALL.len());
597 for c in Class::ALL {
598 assert_eq!(c.chunk().parse::<Class>().unwrap(), c);
599 }
600 }
601
602 #[test]
603 fn unknown_type_lists_the_known_ones() {
604 let err = set(&tcgui_slices())
605 .interface_show("StreamDoc")
606 .unwrap_err();
607 assert!(err.to_string().contains("NetworkInterface"), "got: {err}");
608 }
609
610 #[test]
613 fn topic_info_resolves_service_origins() {
614 let catalog = parse_slice(
615 r#"
616 [registry]
617 version = "1.0"
618 app = "acme"
619 convention = 1
620 [service]
621 name = "catalog"
622 origin = "@catalog"
623 [[subject]]
624 path = "entity/{entity_id}"
625 class = "state"
626 type = "Entity"
627 "#,
628 )
629 .unwrap();
630 let info =
631 set(&[catalog]).topic_info("acme", "acme/v1/@catalog/state/entity/h-3fa9c2d41b7e");
632 assert_eq!(info.verdict, crate::report::TopicVerdict::Registered);
633 assert_eq!(info.subject.as_deref(), Some("entity/{entity_id}"));
634 }
635
636 #[test]
640 fn service_info_spells_the_call_key_for_both_origin_shapes() {
641 let slices = tcgui_slices();
642 let info = set(&slices).service_info("tc", None).expect("tc declares");
643 assert_eq!(info.producer, "tc");
644 assert!(info.service_origin.is_none());
645 assert_eq!(info.procedures.len(), 1);
646 let p = &info.procedures[0];
647 assert_eq!(p.key, "v1/{origin}/@rpc/tc/iface/{iface}/set");
648 assert_eq!(p.reply.as_deref(), Some("Ack"));
649 assert_eq!(p.fanout.as_deref(), Some("one"));
650
651 let service = zenkey::parse_slice(
653 r#"
654 [registry]
655 version = "1.0"
656 app = "t"
657 convention = 1
658 [service]
659 name = "catalog"
660 origin = "@catalog"
661 [[procedure]]
662 path = "link"
663 kind = "write"
664 "#,
665 )
666 .unwrap();
667 let info = set(&[service]).service_info("catalog", None).unwrap();
668 assert_eq!(info.service_origin.as_deref(), Some("@catalog"));
669 assert_eq!(info.procedures[0].key, "v1/@catalog/@rpc/link");
670 }
671
672 #[test]
676 fn an_unknown_producer_or_procedure_lists_what_exists() {
677 let slices = tcgui_slices();
678 let err = set(&slices)
679 .service_info("nope", None)
680 .unwrap_err()
681 .to_string();
682 assert!(err.contains("known producers"), "{err}");
683 assert!(err.contains("tc"), "{err}");
684
685 let err = set(&slices)
686 .service_info("tc", Some("no/such/proc"))
687 .unwrap_err()
688 .to_string();
689 assert!(err.contains("it declares"), "{err}");
690 assert!(err.contains("iface/{iface}/set"), "{err}");
691
692 let one = set(&slices)
694 .service_info("tc", Some("iface/{iface}/set"))
695 .unwrap();
696 assert_eq!(one.procedures.len(), 1);
697 }
698}