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        })
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use zenkey::{RegistrySlice, parse_slice};
322
323    /// The projections are methods on a set; the fixtures are slice lists.
324    fn set(slices: &[zenkey::RegistrySlice]) -> SliceSet {
325        SliceSet::from_slices(slices.to_vec())
326    }
327
328    /// A tcgui-style registry slice — a *foreign* app, read as if off the wire —
329    /// must parse and render without any of tcgui compiled in. This is the whole
330    /// point of the app-agnostic path (tcgui#45): the extra `fanout` field tcgui
331    /// carries is unknown to this build, and `parse_slice` must tolerate it (RFC
332    /// 08 §6 forward-compat), then `topic list` / `service list` / `topic info`
333    /// render sane rows from the parsed slice.
334    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    /// `--type` narrows to carrying subjects; `--deprecated` appends the
379    /// ledger rows with their since/replacement (#57), and stays quiet
380    /// without the flag.
381    #[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        // Subjects still carry their since column.
404        assert_eq!(with.subjects[0].since.as_deref(), Some("0.1"));
405    }
406
407    #[test]
408    fn foreign_tcgui_slice_parses_and_renders() {
409        // parse_slice tolerates the unknown `fanout` field (forward-compat).
410        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        // The optional metadata columns ride the slice when declared.
417        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        // The shared renderers accept a bus-sourced slice with nothing
430        // compiled in — same code path as any `--base` drives.
431        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        // A concrete foreign key refines against the served slice, binding the
440        // `{iface}` variable.
441        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    /// A slice that declares `[[media]]` (RFC 08 §2, reaching the slice in
447    /// v1.16) is readable by a bus explorer — and, the forward-compat half:
448    /// the tcgui slice above declares none and parses unchanged (media
449    /// defaults to empty), so every pre-v1.16 slice keeps parsing.
450    #[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        // The pre-v1.16 posture, pinned: no [[media]] = empty, no error.
464        assert!(parse_slice(TCGUI_SLICE).unwrap().media.is_empty());
465        // A stream needs at least a name and a codec to exist.
466        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    /// A slice that declares `[[blob]]` (RFC 08 §2, v1.8) is readable by a
473    /// bus explorer — which is the whole reason for modelling the plane:
474    /// answering "who serves blobs, and of which tier?" without probing the
475    /// bus for keys nobody may be serving.
476    ///
477    /// The tcgui slice above is deliberately left *without* blob entries, so
478    /// the pair covers both directions: a pre-v1.8 slice still parses (blob
479    /// defaults to empty, no error), and a v1.8 slice surfaces its tiers.
480    #[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        // A blob `reference` is a carried type like any other, so it shows up
496        // in the type vocabulary with an `@blob` carrier.
497        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        // And the loop this test's own comment opened, now closed: the
510        // projection a `zenctl blob list` renders (issue #58).
511        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        // Nobody asked the roster, so nothing may claim who serves it (O4).
517        assert!(list.tiers.iter().all(|t| t.origins.is_not_asked()));
518
519        // Backward direction: the same slice minus the blob entries parses
520        // with an empty list rather than failing — and counts as a slice that
521        // was *read* and declared nothing, which is not the same as unread.
522        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    /// The golden JSON contract (issue #12): `--format json` output is
531    /// stable serde of these reports — pinned here so the fleet extraction
532    /// cannot silently change behavior.
533    #[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    /// O1 (RFC 09 §5.1, issue #34): a non-conformant key is a *described*
560    /// fact, not an error. The old builder bailed here.
561    #[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    /// "A subject that is not registered does not exist" — the verdict says
574    /// so, while the structural facts stay present.
575    #[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    /// An unknown class is no longer this function's error to return: the
588    /// parameter is a `Class`, so it was rejected at whatever edge it came
589    /// in from — with the vocabulary in the message, spelled once (#351).
590    #[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        // And the vocabulary the message lists is the enum's, not a copy.
596        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    /// A service slice's subjects refine through the service origin — the key
611    /// has no producer chunk, and the slice supplies the name.
612    #[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    /// #211: one producer's `@rpc` surface, with the key a caller would use —
637    /// which differs for a service origin, and is the thing a reader should
638    /// not have to reconstruct.
639    #[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        // A service origin carries no producer chunk (RFC 06 §5).
652        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    /// A name nothing declares is a typo far more often than a silent fleet,
673    /// so it says what *is* declared rather than returning an empty list —
674    /// which would read as "this producer offers nothing" (O4).
675    #[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        // A path that does exist filters to exactly it.
693        let one = set(&slices)
694            .service_info("tc", Some("iface/{iface}/set"))
695            .unwrap();
696        assert_eq!(one.procedures.len(), 1);
697    }
698}