Skip to main content

zenkey_fleet/model/
project.rs

1//! Everything answerable from a set of registry slices, without a bus.
2//!
3//! A slice is a slice regardless of where it was read — each producer's served
4//! `introspect` reply off the live bus ([`crate::fleet_registry`]) or a local
5//! `registry/*.toml` file ([`SliceSet::from_dirs`]). These projections take a
6//! [`SliceSet`] and are source-agnostic; nothing app-specific is compiled in.
7//!
8//! They lived in `zenctl` until issue #205. The analogous `blob_list`
9//! projection was already here, and `schema_dump` too, so the split was
10//! arbitrary — and it cost: zengui could render a `TopicList` but had no way
11//! to build one, which is why it never showed a topic list at all. Nothing in
12//! any of these functions needs a session, a terminal or an exit code, which
13//! is the whole test for whether it belongs in the engine.
14
15use 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    /// `topic list` — every registered subject in the given slices.
26    ///
27    /// **Declared, not observed.** A pattern with a trailing rest-variable
28    /// (`{path...}`) stands for a whole family whose real members only exist on the
29    /// wire: proxy producers register `{device}/{path...}` by design, because
30    /// their metric tree belongs to the polled device, not to us. For those, this
31    /// command can only tell you the shape. `zenctl echo` is what tells you
32    /// the members.
33    /// `class` is a [`Class`], so there is no validation here and no error
34    /// to return for one: a caller that has a `Class` has already parsed it,
35    /// at whatever edge it came in from. This function used to re-check a
36    /// `&str` against its own copy of the vocabulary, with its own copy of
37    /// the sentence (#351).
38    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            // --deprecated: the ledger-backed retirements this build still
73            // serves — RFC 08 §6 names "which hosts still serve a deprecated
74            // subject" as a headline buy of introspection. A ledger entry has no
75            // class or type, so the narrowing filters exclude these rows.
76            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    /// `topic info` — refine one concrete wire key against the registry slices.
102    ///
103    /// This is the slice-level parse direction (RFC 08 §1): the key is parsed
104    /// **structurally** (grammar only), then its subject tail is matched against
105    /// the producer's slice, binding variables by name — which is why the output
106    /// can say `mount=root` rather than `parts[6]`.
107    pub fn topic_info(&self, base: &str, key: &str) -> TopicInfo {
108        // Infallible since issue #34: the engine's describe_key implements the
109        // RFC 09 §5.1 O1/O2 ladder (a non-conformant key is a fact, not an
110        // error) with SliceSet::refine's most-literal-first precedence — the old
111        // local matcher scanned in declaration order and could disagree with
112        // generated consumers.
113        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    /// `service info` — one producer's `@rpc` surface, with call keys.
143    ///
144    /// `Err` when nothing declares the producer, listing what does: a name
145    /// that answers nowhere is a typo far more often than a silent fleet, and
146    /// the alternative — an empty procedure list — reads as "this producer
147    /// offers nothing", which is a verdict this cannot support (O4).
148    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            // The caller named a producer; nothing was asked of the bus.
153            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                // A service origin has no producer chunk (RFC 06 §5).
172                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    /// `interface list` — every payload type the slices declare, with carrier
215    /// counts. Field-level schema is deliberately absent: type definitions stay
216    /// with the owning application (RFC 08 §5), so this maps the vocabulary, not
217    /// the shapes.
218    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            // Blob reference types (RFC 08 §2, v1.8) are carried types like any
234            // other — the payload that must convey a blob's content root.
235            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    /// `interface show` — one payload type, and every subject/procedure that
253    /// carries it (the reverse of the registry's binding).
254    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            // A blob entry has no path (RFC 08 §2), so the tier token stands in —
268            // it is the chunk that identifies the family, exactly as a procedure
269            // path does on `@rpc`.
270            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            // Offline by construction: schemas come from the bus, and the
311            // caller fills them in only when `--schema` asked for them —
312            // `NotAsked` says the bus was never asked (O4, R4).
313            schemas: crate::report::Asked::NotAsked,
314            // Likewise: drift is a verdict over what the bus served, and
315            // nothing was asked of it here.
316            drift: Vec::new(),
317        })
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use zenkey::{RegistrySlice, parse_slice};
325
326    /// The projections are methods on a set; the fixtures are slice lists.
327    fn set(slices: &[zenkey::RegistrySlice]) -> SliceSet {
328        SliceSet::from_slices(slices.to_vec())
329    }
330
331    /// A tcgui-style registry slice — a *foreign* app, read as if off the wire —
332    /// must parse and render without any of tcgui compiled in. This is the whole
333    /// point of the app-agnostic path (tcgui#45): the extra `fanout` field tcgui
334    /// carries is unknown to this build, and `parse_slice` must tolerate it (RFC
335    /// 08 §6 forward-compat), then `topic list` / `service list` / `topic info`
336    /// render sane rows from the parsed slice.
337    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    /// `--type` narrows to carrying subjects; `--deprecated` appends the
382    /// ledger rows with their since/replacement (#57), and stays quiet
383    /// without the flag.
384    #[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        // Subjects still carry their since column.
407        assert_eq!(with.subjects[0].since.as_deref(), Some("0.1"));
408    }
409
410    #[test]
411    fn foreign_tcgui_slice_parses_and_renders() {
412        // parse_slice tolerates the unknown `fanout` field (forward-compat).
413        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        // The optional metadata columns ride the slice when declared.
420        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        // The shared renderers accept a bus-sourced slice with nothing
433        // compiled in — same code path as any `--base` drives.
434        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        // A concrete foreign key refines against the served slice, binding the
443        // `{iface}` variable.
444        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    /// A slice that declares `[[media]]` (RFC 08 §2, reaching the slice in
450    /// v1.16) is readable by a bus explorer — and, the forward-compat half:
451    /// the tcgui slice above declares none and parses unchanged (media
452    /// defaults to empty), so every pre-v1.16 slice keeps parsing.
453    #[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        // The pre-v1.16 posture, pinned: no [[media]] = empty, no error.
467        assert!(parse_slice(TCGUI_SLICE).unwrap().media.is_empty());
468        // A stream needs at least a name and a codec to exist.
469        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    /// A slice that declares `[[blob]]` (RFC 08 §2, v1.8) is readable by a
476    /// bus explorer — which is the whole reason for modelling the plane:
477    /// answering "who serves blobs, and of which tier?" without probing the
478    /// bus for keys nobody may be serving.
479    ///
480    /// The tcgui slice above is deliberately left *without* blob entries, so
481    /// the pair covers both directions: a pre-v1.8 slice still parses (blob
482    /// defaults to empty, no error), and a v1.8 slice surfaces its tiers.
483    #[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        // A blob `reference` is a carried type like any other, so it shows up
499        // in the type vocabulary with an `@blob` carrier.
500        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        // And the loop this test's own comment opened, now closed: the
513        // projection a `zenctl blob list` renders (issue #58).
514        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        // Nobody asked the roster, so nothing may claim who serves it (O4).
520        assert!(list.tiers.iter().all(|t| t.origins.is_not_asked()));
521
522        // Backward direction: the same slice minus the blob entries parses
523        // with an empty list rather than failing — and counts as a slice that
524        // was *read* and declared nothing, which is not the same as unread.
525        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    /// The golden JSON contract (issue #12): `--format json` output is
534    /// stable serde of these reports — pinned here so the fleet extraction
535    /// cannot silently change behavior.
536    #[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    /// O1 (RFC 09 §5.1, issue #34): a non-conformant key is a *described*
563    /// fact, not an error. The old builder bailed here.
564    #[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    /// "A subject that is not registered does not exist" — the verdict says
577    /// so, while the structural facts stay present.
578    #[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    /// An unknown class is no longer this function's error to return: the
591    /// parameter is a `Class`, so it was rejected at whatever edge it came
592    /// in from — with the vocabulary in the message, spelled once (#351).
593    #[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        // And the vocabulary the message lists is the enum's, not a copy.
599        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    /// A service slice's subjects refine through the service origin — the key
614    /// has no producer chunk, and the slice supplies the name.
615    #[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    /// #211: one producer's `@rpc` surface, with the key a caller would use —
640    /// which differs for a service origin, and is the thing a reader should
641    /// not have to reconstruct.
642    #[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        // A service origin carries no producer chunk (RFC 06 §5).
655        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    /// A name nothing declares is a typo far more often than a silent fleet,
676    /// so it says what *is* declared rather than returning an empty list —
677    /// which would read as "this producer offers nothing" (O4).
678    #[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        // A path that does exist filters to exactly it.
696        let one = set(&slices)
697            .service_info("tc", Some("iface/{iface}/set"))
698            .unwrap();
699        assert_eq!(one.procedures.len(), 1);
700    }
701}