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 drift: Vec::new(),
317 })
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use zenkey::{RegistrySlice, parse_slice};
325
326 fn set(slices: &[zenkey::RegistrySlice]) -> SliceSet {
328 SliceSet::from_slices(slices.to_vec())
329 }
330
331 const TCGUI_SLICE: &str = r#"
338 [registry]
339 version = "0.3"
340 app = "tcgui"
341 convention = 1
342
343 [producer]
344 name = "tc"
345 description = "traffic-control netem shaper"
346
347 [[subject]]
348 path = "iface/{iface}/state"
349 class = "state"
350 type = "NetworkInterface"
351 fanout = "per-iface"
352 since = "0.1"
353 ttl_s = 30
354 qos = "refreshed"
355 description = "current netem config on an interface"
356
357 [[subject]]
358 path = "health"
359 class = "state"
360 type = "BackendHealthStatus"
361 since = "0.1"
362
363 [[procedure]]
364 path = "iface/{iface}/set"
365 kind = "write"
366 reply = "Ack"
367 fanout = "one"
368 since = "0.2"
369 description = "apply a netem config"
370
371 [[deprecated]]
372 path = "iface/{iface}/status"
373 since = "0.2"
374 replaced_by = "iface/{iface}/state"
375 "#;
376
377 fn tcgui_slices() -> Vec<RegistrySlice> {
378 vec![parse_slice(TCGUI_SLICE).unwrap()]
379 }
380
381 #[test]
385 fn type_and_deprecated_filters() {
386 let slices = tcgui_slices();
387
388 let by_type = set(&slices)
389 .topic_list(None, None, Some("NetworkInterface"), false)
390 .unwrap();
391 assert_eq!(by_type.subjects.len(), 1);
392 assert_eq!(by_type.subjects[0].path, "iface/{iface}/state");
393
394 let without = set(&slices).topic_list(None, None, None, false).unwrap();
395 assert!(without.subjects.iter().all(|s| !s.deprecated));
396
397 let with = set(&slices).topic_list(None, None, None, true).unwrap();
398 let retired: Vec<_> = with.subjects.iter().filter(|s| s.deprecated).collect();
399 assert_eq!(retired.len(), 1);
400 assert_eq!(retired[0].path, "iface/{iface}/status");
401 assert_eq!(retired[0].deprecated_since.as_deref(), Some("0.2"));
402 assert_eq!(
403 retired[0].replaced_by.as_deref(),
404 Some("iface/{iface}/state")
405 );
406 assert_eq!(with.subjects[0].since.as_deref(), Some("0.1"));
408 }
409
410 #[test]
411 fn foreign_tcgui_slice_parses_and_renders() {
412 let slice = parse_slice(TCGUI_SLICE).unwrap();
414 assert_eq!(slice.name, "tc");
415 assert_eq!(slice.app, "tcgui");
416 assert_eq!(slice.subjects.len(), 2);
417 assert_eq!(slice.procedures.len(), 1);
418 assert_eq!(slice.subjects[0].type_name, "NetworkInterface");
419 assert_eq!(slice.subjects[0].ttl_s, Some(30));
421 assert_eq!(
422 slice.subjects[0].qos.as_ref().and_then(Declared::known),
423 Some(&zenkey::QosProfile::Refreshed)
424 );
425 assert_eq!(
426 slice.procedures[0].kind.as_ref().and_then(Declared::known),
427 Some(&zenkey::ProcedureKind::Write)
428 );
429
430 let slices = tcgui_slices();
431
432 set(&slices).topic_list(None, None, None, false).unwrap();
435 set(&slices)
436 .topic_list(Some("tc"), Some(Class::State), None, false)
437 .unwrap();
438 set(&slices).service_list(Some("tc"));
439 set(&slices).interface_list();
440 set(&slices).interface_show("NetworkInterface").unwrap();
441
442 let info =
445 set(&slices).topic_info("tcgui", "tcgui/v1/h-3fa9c2d41b7e/state/tc/iface/eth0/state");
446 assert_eq!(info.verdict, crate::report::TopicVerdict::Registered);
447 }
448
449 #[test]
454 fn a_media_bearing_slice_is_readable_by_an_explorer() {
455 let src = format!(
456 "{}\n[[media]]\npath = \"{{stream}}/preview/jpeg\"\nencoding = \"image/jpeg\"\n\
457 attachment = \"FrameMeta\"\ncardinality = 16\nsince = \"1.0\"\n",
458 TCGUI_SLICE
459 );
460 let slice = parse_slice(&src).unwrap();
461 assert_eq!(slice.media.len(), 1);
462 assert_eq!(slice.media[0].path, "{stream}/preview/jpeg");
463 assert_eq!(slice.media[0].encoding.as_encoding_str(), "image/jpeg");
464 assert_eq!(slice.media[0].attachment.as_deref(), Some("FrameMeta"));
465
466 assert!(parse_slice(TCGUI_SLICE).unwrap().media.is_empty());
468 assert!(
470 parse_slice(&format!("{}\n[[media]]\npath = \"x\"\n", TCGUI_SLICE)).is_err(),
471 "encoding is required — the codec is declared, never sniffed"
472 );
473 }
474
475 #[test]
484 fn a_blob_bearing_slice_is_readable_by_an_explorer() {
485 let src = format!(
486 "{}\n[[blob]]\ntier = \"artifact\"\nendpoints = [\"manifest\", \"have\"]\n\
487 reference = \"Delivery\"\nsince = \"1.8\"\n\
488 [[blob]]\ntier = \"store\"\nalgo = \"blake3\"\nsince = \"1.8\"\n",
489 TCGUI_SLICE
490 );
491 let slice = parse_slice(&src).unwrap();
492 assert!(slice.serves_blob_tier(zenkey::BlobTier::Artifact));
493 assert!(slice.serves_blob_tier(zenkey::BlobTier::Store));
494 assert!(!slice.serves_blob_tier(zenkey::BlobTier::Tree));
495 assert_eq!(slice.blob[0].endpoints, ["manifest", "have"]);
496 assert_eq!(slice.blob[1].algo.as_deref(), Some("blake3"));
497
498 let slices = vec![slice];
501 let types = set(&slices).interface_list();
502 assert!(types.types.iter().any(|t| t.name == "Delivery"));
503 let show = set(&slices).interface_show("Delivery").unwrap();
504 assert!(
505 show.carriers
506 .iter()
507 .any(|c| c.class == "@blob" && c.path == "artifact"),
508 "{:?}",
509 show.carriers
510 );
511
512 let list = crate::blob_list(&slices, None, crate::report::BlobListSource::RegistryDirs);
515 assert_eq!(list.tiers.len(), 2);
516 assert_eq!(list.slices_considered, 1);
517 assert_eq!(list.slices_without_blob, 0);
518 assert!(list.tiers.iter().all(|t| t.known_tier));
519 assert!(list.tiers.iter().all(|t| t.origins.is_not_asked()));
521
522 let bare = parse_slice(TCGUI_SLICE).unwrap();
526 assert!(bare.blob.is_empty());
527 let none = crate::blob_list(&[bare], None, crate::report::BlobListSource::RegistryDirs);
528 assert!(none.tiers.is_empty());
529 assert_eq!(none.slices_considered, 1);
530 assert_eq!(none.slices_without_blob, 1);
531 }
532
533 #[test]
537 fn reports_serialize_to_stable_json() {
538 let slices = tcgui_slices();
539 let list = set(&slices)
540 .topic_list(Some("tc"), Some(Class::State), None, false)
541 .unwrap();
542 let json = serde_json::to_value(&list).unwrap();
543 assert_eq!(json["subjects"][0]["producer"], "tc");
544 assert_eq!(json["subjects"][0]["path"], "iface/{iface}/state");
545 assert_eq!(json["subjects"][0]["type_name"], "NetworkInterface");
546 assert_eq!(json["subjects"][0]["open_ended"], false);
547
548 let info =
549 set(&slices).topic_info("tcgui", "tcgui/v1/h-3fa9c2d41b7e/state/tc/iface/eth0/state");
550 let json = serde_json::to_value(&info).unwrap();
551 assert_eq!(json["verdict"], "registered");
552 assert_eq!(json["variables"]["iface"], "eth0");
553 assert_eq!(json["payload_type"], "NetworkInterface");
554 assert_eq!(json["ttl_s"], 30);
555
556 let services = set(&slices).service_list(None);
557 let json = serde_json::to_value(&services).unwrap();
558 assert_eq!(json["procedures"][0]["kind"], "write");
559 assert_eq!(json["procedures"][0]["reply"], "Ack");
560 }
561
562 #[test]
565 fn topic_info_describes_a_non_v1_key_instead_of_rejecting_it() {
566 use crate::report::TopicVerdict;
567 let info = set(&tcgui_slices()).topic_info("tcgui", "tcgui/tc/eth0/state");
568 assert_eq!(info.verdict, TopicVerdict::NotV1);
569 assert!(info.note.contains("fact, not an error"), "{}", info.note);
570 assert!(
571 info.payload_type.is_none(),
572 "nothing below the rung is invented"
573 );
574 }
575
576 #[test]
579 fn topic_info_reports_unregistered_subjects() {
580 use crate::report::TopicVerdict;
581 let info = set(&tcgui_slices()).topic_info(
582 "tcgui",
583 "tcgui/v1/h-3fa9c2d41b7e/state/tc/not_a_real_subject",
584 );
585 assert_eq!(info.verdict, TopicVerdict::Unregistered);
586 assert_eq!(info.producer.as_deref(), Some("tc"));
587 assert!(info.payload_type.is_none());
588 }
589
590 #[test]
594 fn an_unknown_class_is_rejected_at_the_parse_not_here() {
595 let err = "alerts".parse::<Class>().unwrap_err().to_string();
596 assert!(err.contains("unknown class"), "got: {err}");
597 assert!(err.contains("telemetry, state, events"), "got: {err}");
598 assert_eq!(Class::chunks().len(), Class::ALL.len());
600 for c in Class::ALL {
601 assert_eq!(c.chunk().parse::<Class>().unwrap(), c);
602 }
603 }
604
605 #[test]
606 fn unknown_type_lists_the_known_ones() {
607 let err = set(&tcgui_slices())
608 .interface_show("StreamDoc")
609 .unwrap_err();
610 assert!(err.to_string().contains("NetworkInterface"), "got: {err}");
611 }
612
613 #[test]
616 fn topic_info_resolves_service_origins() {
617 let catalog = parse_slice(
618 r#"
619 [registry]
620 version = "1.0"
621 app = "acme"
622 convention = 1
623 [service]
624 name = "catalog"
625 origin = "@catalog"
626 [[subject]]
627 path = "entity/{entity_id}"
628 class = "state"
629 type = "Entity"
630 "#,
631 )
632 .unwrap();
633 let info =
634 set(&[catalog]).topic_info("acme", "acme/v1/@catalog/state/entity/h-3fa9c2d41b7e");
635 assert_eq!(info.verdict, crate::report::TopicVerdict::Registered);
636 assert_eq!(info.subject.as_deref(), Some("entity/{entity_id}"));
637 }
638
639 #[test]
643 fn service_info_spells_the_call_key_for_both_origin_shapes() {
644 let slices = tcgui_slices();
645 let info = set(&slices).service_info("tc", None).expect("tc declares");
646 assert_eq!(info.producer, "tc");
647 assert!(info.service_origin.is_none());
648 assert_eq!(info.procedures.len(), 1);
649 let p = &info.procedures[0];
650 assert_eq!(p.key, "v1/{origin}/@rpc/tc/iface/{iface}/set");
651 assert_eq!(p.reply.as_deref(), Some("Ack"));
652 assert_eq!(p.fanout.as_deref(), Some("one"));
653
654 let service = zenkey::parse_slice(
656 r#"
657 [registry]
658 version = "1.0"
659 app = "t"
660 convention = 1
661 [service]
662 name = "catalog"
663 origin = "@catalog"
664 [[procedure]]
665 path = "link"
666 kind = "write"
667 "#,
668 )
669 .unwrap();
670 let info = set(&[service]).service_info("catalog", None).unwrap();
671 assert_eq!(info.service_origin.as_deref(), Some("@catalog"));
672 assert_eq!(info.procedures[0].key, "v1/@catalog/@rpc/link");
673 }
674
675 #[test]
679 fn an_unknown_producer_or_procedure_lists_what_exists() {
680 let slices = tcgui_slices();
681 let err = set(&slices)
682 .service_info("nope", None)
683 .unwrap_err()
684 .to_string();
685 assert!(err.contains("known producers"), "{err}");
686 assert!(err.contains("tc"), "{err}");
687
688 let err = set(&slices)
689 .service_info("tc", Some("no/such/proc"))
690 .unwrap_err()
691 .to_string();
692 assert!(err.contains("it declares"), "{err}");
693 assert!(err.contains("iface/{iface}/set"), "{err}");
694
695 let one = set(&slices)
697 .service_info("tc", Some("iface/{iface}/set"))
698 .unwrap();
699 assert_eq!(one.procedures.len(), 1);
700 }
701}