Skip to main content

zenkey_fleet/report/
topic.rs

1//! The topic plane: what a key *is*, as a row and as a document.
2//!
3//! [`TopicInfo`] is the partial-never-absent one (RFC 09 §5.1 O1/O2): the
4//! description ladder stops where the facts stop, and every rung below the
5//! stop is absent rather than invented, with [`TopicVerdict`] naming which
6//! rung was reached.
7
8use crate::model::facts::{KeyDescription, KeyShape, Registration};
9use serde::Serialize;
10use std::collections::BTreeMap;
11use zenkey::RateClass;
12
13#[derive(Debug, Clone, Serialize)]
14pub struct TopicRow {
15    pub producer: String,
16    pub registry_version: String,
17    pub class: String,
18    pub path: String,
19    pub type_name: String,
20    /// Trailing `{var...}` family: the registry fixes the shape, not the
21    /// members.
22    pub open_ended: bool,
23    /// Registry version the subject first appeared in, when declared.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub since: Option<String>,
26    /// A retired subject (from the slice's `[[deprecated]]` ledger, RFC 08
27    /// §6) — rendered only under `topic list --deprecated`.
28    #[serde(skip_serializing_if = "std::ops::Not::not")]
29    pub deprecated: bool,
30    /// When it was retired, if the ledger says.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub deprecated_since: Option<String>,
33    /// The declared replacement subject, if any.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub replaced_by: Option<String>,
36    /// The declared key-population bound (RFC 08 §2: mandatory on any
37    /// `{var}` pattern; the budget review enforces). Additive (#221) — old
38    /// consumers keep parsing.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub cardinality: Option<i64>,
41    /// Declared-vs-observed key population — present only under
42    /// `topic list --budget` (#221).
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub budget: Option<BudgetCell>,
45}
46
47/// One `{var}` row's declared-vs-observed key population (#221).
48///
49/// Judged **per origin**: RFC 04 §1's table bounds cardinality per producer,
50/// so one origin over the bound is conclusive and several origins' healthy
51/// populations are never summed into a fake violation. `over` is the only
52/// verdict this cell carries — observed *under* declared is not one (a
53/// bounded window proves a lower bound, never the population, RFC 09 §5.1
54/// O6), and a `{path...}` family is `exempt` and says so rather than
55/// passing (the RFC 08 §6.1 v1.20 shape).
56#[derive(Debug, Clone, Serialize)]
57pub struct BudgetCell {
58    /// The declared bound, when the subject declares one.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub declared: Option<i64>,
61    /// Distinct concrete keys observed across all origins over the window.
62    pub observed: usize,
63    /// Origins that expanded this family.
64    pub origins: usize,
65    /// The origin with the most expansions — the one the bound judges.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub worst_origin: Option<String>,
68    /// That origin's distinct-key count (0 = family unobserved).
69    pub worst_observed: usize,
70    /// `Some("rest-variable")`: a `{path...}` family, unbounded by
71    /// construction — exempt, and saying so.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub exempt: Option<String>,
74    /// The worst origin exceeds the declared bound — the finding.
75    pub over: bool,
76    /// Example expansions from the worst origin (capped).
77    #[serde(skip_serializing_if = "Vec::is_empty")]
78    pub examples: Vec<String>,
79}
80
81/// What a `--budget` column's numbers rest on (#221) — the O5/O6 coverage
82/// statement: the window, the exact scopes watched, and the observer's
83/// bound. Without it "observed 3" reads as "the population is 3", which a
84/// bounded sweep never established.
85#[derive(Debug, Clone, Serialize)]
86pub struct BudgetWindow {
87    pub window_s: f64,
88    /// The selectors actually watched — coverage is a statement, not a vibe.
89    pub scopes: Vec<String>,
90    /// Distinct keys the bounded observer retained.
91    pub keys: usize,
92    /// Keys the observer retired to stay within its bound; non-zero makes
93    /// every observed count a lower bound twice over.
94    pub evicted: u64,
95}
96
97#[derive(Debug, Clone, Serialize)]
98pub struct TopicList {
99    pub subjects: Vec<TopicRow>,
100    /// The observation behind the rows' budget cells — present only under
101    /// `topic list --budget` (#221).
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub budget: Option<BudgetWindow>,
104}
105
106/// One key, described as far as the RFC 09 §5.1 ladder reached.
107///
108/// Redesigned in issue #34 from an all-or-nothing struct (whose builder
109/// hard-errored on any key that was not a registered v1 data subject — an O1
110/// violation) into a **partial** report: every key yields one, and `verdict`
111/// says how far it got. Fields below the ladder's failure point are absent,
112/// never defaulted.
113#[derive(Debug, Clone, Serialize)]
114pub struct TopicInfo {
115    pub key: String,
116    /// The ladder verdict, machine-stable (see [`TopicVerdict`]).
117    pub verdict: TopicVerdict,
118    /// Human-readable elaboration of the verdict (why, and what would answer
119    /// it) — rendered, never parsed.
120    pub note: String,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub origin: Option<String>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub producer: Option<String>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub class: Option<String>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub subject: Option<String>,
129    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
130    pub variables: BTreeMap<String, String>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub payload_type: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub unit: Option<String>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub qos: Option<String>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub ttl_s: Option<i64>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub rate: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub cardinality: Option<i64>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub encoding: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub since: Option<String>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub description: Option<String>,
149}
150
151/// Where the ladder stopped. Serialized snake_case; stable for scripts.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
153#[serde(rename_all = "snake_case")]
154pub enum TopicVerdict {
155    /// Parses, refines, declared — the full story is present.
156    Registered,
157    /// Parses as a v1 data key; the producer's slice does not declare it.
158    Unregistered,
159    /// Parses; no loaded slice covers this producer (or service origin).
160    NoSliceForProducer,
161    /// Parses, but onto a verbatim plane — there is no `[[subject]]` surface
162    /// to consult (RFC 03 §1.4).
163    NotADataClass,
164    /// A legal Zenoh key that is not this convention's (O1: a fact).
165    NotV1,
166    /// Sits under a different deployment base than the one configured.
167    NotUnderBase,
168    /// Parses as a data key, but no registry has been loaded — "not asked"
169    /// is not "answered no" (O4).
170    RegistryNotLoaded,
171}
172
173impl TopicInfo {
174    /// Render a [`KeyDescription`] into the report shape.
175    pub fn from_description(d: &KeyDescription) -> TopicInfo {
176        let mut info = TopicInfo {
177            key: d.key.clone(),
178            verdict: TopicVerdict::NotV1,
179            note: String::new(),
180            origin: None,
181            producer: None,
182            class: None,
183            subject: None,
184            variables: BTreeMap::new(),
185            payload_type: None,
186            unit: None,
187            qos: None,
188            ttl_s: None,
189            rate: None,
190            cardinality: None,
191            encoding: None,
192            since: None,
193            description: None,
194        };
195        match &d.facts.shape {
196            KeyShape::NotUnderBase => {
197                info.verdict = TopicVerdict::NotUnderBase;
198                info.note = "under a different deployment base than the configured one \
199                             (RFC 03 §1.1); `zenctl base list` discovers the bases in use"
200                    .into();
201                return info;
202            }
203            KeyShape::Unparsed { reason } => {
204                info.verdict = TopicVerdict::NotV1;
205                info.note = format!(
206                    "not a keyspace-v2 key — a fact, not an error (RFC 09 §5.1 O1): {reason}"
207                );
208                return info;
209            }
210            KeyShape::V1(v) => {
211                info.origin = Some(v.origin.clone());
212                info.class = Some(v.class.clone());
213                info.producer = v.producer.clone();
214            }
215        }
216        match &d.facts.registration {
217            Registration::Registered(s) => {
218                info.verdict = TopicVerdict::Registered;
219                info.subject = Some(s.path.clone());
220                info.variables = s.vars.iter().cloned().collect();
221                info.payload_type = Some(s.type_name.clone());
222                info.unit = s.unit.clone();
223                info.qos = s.qos.as_ref().map(|q| q.token().to_string());
224                info.encoding = s.encoding.as_ref().map(|e| e.as_encoding_str().to_string());
225                info.ttl_s = s.ttl_s;
226                // Declared since v1.0, dropped on this path until #221 — the
227                // field existed and was never filled.
228                info.cardinality = s.cardinality;
229                // R2, the third recurrence of the same class (cardinality
230                // pre-#221, then these): `rate` reached `SubjectFacts` and
231                // died at this boundary; `since`/`description` never even
232                // left the slice. The no-dead-field pin in
233                // `report_contract.rs` now guards the whole struct.
234                info.rate = s.rate.as_ref().map(RateClass::token);
235                info.since = s.since.clone();
236                info.description = s.description.clone();
237            }
238            Registration::Unregistered => {
239                info.verdict = TopicVerdict::Unregistered;
240                info.note = "parses as a v1 data key, but the producer's slice does not \
241                             declare this subject — for a conforming producer, a subject \
242                             that is not registered does not exist (RFC 08)"
243                    .into();
244            }
245            Registration::NoSliceForProducer => {
246                info.verdict = TopicVerdict::NoSliceForProducer;
247                info.note = "no loaded registry slice covers this producer — `--registry \
248                             <dir>` supplies slices offline; on-bus they come from \
249                             introspect (RFC 08 §6)"
250                    .into();
251            }
252            Registration::Unknown => {
253                info.verdict = TopicVerdict::RegistryNotLoaded;
254                info.note = "no registry loaded — \"not asked\" is not \"answered no\" \
255                             (RFC 09 §5.1 O4)"
256                    .into();
257            }
258            Registration::NotApplicable => {
259                info.verdict = TopicVerdict::NotADataClass;
260                info.note = "a verbatim plane, not a data class — there is no [[subject]] \
261                             surface to describe (RFC 03 §1.4)"
262                    .into();
263            }
264        }
265        info
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::model::facts::describe_key;
273    use crate::model::registry::SliceSet;
274
275    /// O1/O2 end to end: every kind of key yields a TopicInfo, and the
276    /// verdicts are distinct.
277    #[test]
278    fn topic_info_is_partial_never_absent() {
279        let cases = [
280            ("demo/example/foo", TopicVerdict::NotV1),
281            (
282                "v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect",
283                TopicVerdict::NotADataClass,
284            ),
285            (
286                "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
287                TopicVerdict::RegistryNotLoaded,
288            ),
289        ];
290        for (key, want) in cases {
291            let info = TopicInfo::from_description(&describe_key("", key, None));
292            assert_eq!(info.verdict, want, "{key}");
293            assert!(!info.note.is_empty(), "{key} must explain itself");
294        }
295        let info = TopicInfo::from_description(&describe_key(
296            "zensight",
297            "other/v1/h-3fa9c2d41b7e/state/x/y",
298            None,
299        ));
300        assert_eq!(info.verdict, TopicVerdict::NotUnderBase);
301        // Partial means partial: nothing below the failure point is invented.
302        assert!(info.origin.is_none() && info.payload_type.is_none());
303        // Loaded-and-empty is a different fact from not-loaded (O4).
304        let empty = SliceSet::default();
305        let info = TopicInfo::from_description(&describe_key(
306            "",
307            "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
308            Some(&empty),
309        ));
310        assert_eq!(info.verdict, TopicVerdict::NoSliceForProducer);
311        // The ladder reached the parse rung, so structural facts ARE present…
312        assert_eq!(info.origin.as_deref(), Some("h-3fa9c2d41b7e"));
313        // …but no registry facts were invented.
314        assert!(info.payload_type.is_none());
315    }
316}