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    /// The declared `kind` token (RFC 08 §2, v1.32), when the registry
136    /// declares one.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub kind: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub qos: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub ttl_s: Option<i64>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub rate: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub cardinality: Option<i64>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub encoding: Option<String>,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub since: Option<String>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub description: Option<String>,
153}
154
155/// Where the ladder stopped. Serialized snake_case; stable for scripts.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
157#[serde(rename_all = "snake_case")]
158pub enum TopicVerdict {
159    /// Parses, refines, declared — the full story is present.
160    Registered,
161    /// Parses as a v1 data key; the producer's slice does not declare it.
162    Unregistered,
163    /// Parses; no loaded slice covers this producer (or service origin).
164    NoSliceForProducer,
165    /// Parses, but onto a verbatim plane — there is no `[[subject]]` surface
166    /// to consult (RFC 03 §1.4).
167    NotADataClass,
168    /// A legal Zenoh key that is not this convention's (O1: a fact).
169    NotV1,
170    /// Sits under a different deployment base than the one configured.
171    NotUnderBase,
172    /// Parses as a data key, but no registry has been loaded — "not asked"
173    /// is not "answered no" (O4).
174    RegistryNotLoaded,
175}
176
177impl TopicInfo {
178    /// Render a [`KeyDescription`] into the report shape.
179    pub fn from_description(d: &KeyDescription) -> TopicInfo {
180        let mut info = TopicInfo {
181            key: d.key.clone(),
182            verdict: TopicVerdict::NotV1,
183            note: String::new(),
184            origin: None,
185            producer: None,
186            class: None,
187            subject: None,
188            variables: BTreeMap::new(),
189            payload_type: None,
190            unit: None,
191            kind: None,
192            qos: None,
193            ttl_s: None,
194            rate: None,
195            cardinality: None,
196            encoding: None,
197            since: None,
198            description: None,
199        };
200        match &d.facts.shape {
201            KeyShape::NotUnderBase => {
202                info.verdict = TopicVerdict::NotUnderBase;
203                info.note = "under a different deployment base than the configured one \
204                             (RFC 03 §1.1); `zenctl base list` discovers the bases in use"
205                    .into();
206                return info;
207            }
208            KeyShape::Unparsed { reason } => {
209                info.verdict = TopicVerdict::NotV1;
210                info.note = format!(
211                    "not a keyspace-v2 key — a fact, not an error (RFC 09 §5.1 O1): {reason}"
212                );
213                return info;
214            }
215            KeyShape::V1(v) => {
216                info.origin = Some(v.origin.clone());
217                info.class = Some(v.class.clone());
218                info.producer = v.producer.clone();
219            }
220        }
221        match &d.facts.registration {
222            Registration::Registered(s) => {
223                info.verdict = TopicVerdict::Registered;
224                info.subject = Some(s.path.clone());
225                info.variables = s.vars.iter().cloned().collect();
226                info.payload_type = Some(s.type_name.clone());
227                info.unit = s.unit.clone();
228                info.kind = s.kind.as_ref().map(|k| k.token().to_string());
229                info.qos = s.qos.as_ref().map(|q| q.token().to_string());
230                info.encoding = s.encoding.as_ref().map(|e| e.as_encoding_str().to_string());
231                info.ttl_s = s.ttl_s;
232                // Declared since v1.0, dropped on this path until #221 — the
233                // field existed and was never filled.
234                info.cardinality = s.cardinality;
235                // R2, the third recurrence of the same class (cardinality
236                // pre-#221, then these): `rate` reached `SubjectFacts` and
237                // died at this boundary; `since`/`description` never even
238                // left the slice. The no-dead-field pin in
239                // `report_contract.rs` now guards the whole struct.
240                info.rate = s.rate.as_ref().map(RateClass::token);
241                info.since = s.since.clone();
242                info.description = s.description.clone();
243            }
244            Registration::Unregistered => {
245                info.verdict = TopicVerdict::Unregistered;
246                info.note = "parses as a v1 data key, but the producer's slice does not \
247                             declare this subject — for a conforming producer, a subject \
248                             that is not registered does not exist (RFC 08)"
249                    .into();
250            }
251            Registration::NoSliceForProducer => {
252                info.verdict = TopicVerdict::NoSliceForProducer;
253                info.note = "no loaded registry slice covers this producer — `--registry \
254                             <dir>` supplies slices offline; on-bus they come from \
255                             introspect (RFC 08 §6)"
256                    .into();
257            }
258            Registration::Unknown => {
259                info.verdict = TopicVerdict::RegistryNotLoaded;
260                info.note = "no registry loaded — \"not asked\" is not \"answered no\" \
261                             (RFC 09 §5.1 O4)"
262                    .into();
263            }
264            Registration::NotApplicable => {
265                info.verdict = TopicVerdict::NotADataClass;
266                info.note = "a verbatim plane, not a data class — there is no [[subject]] \
267                             surface to describe (RFC 03 §1.4)"
268                    .into();
269            }
270        }
271        info
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::model::facts::describe_key;
279    use crate::model::registry::SliceSet;
280
281    /// O1/O2 end to end: every kind of key yields a TopicInfo, and the
282    /// verdicts are distinct.
283    #[test]
284    fn topic_info_is_partial_never_absent() {
285        let cases = [
286            ("demo/example/foo", TopicVerdict::NotV1),
287            (
288                "v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect",
289                TopicVerdict::NotADataClass,
290            ),
291            (
292                "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
293                TopicVerdict::RegistryNotLoaded,
294            ),
295        ];
296        for (key, want) in cases {
297            let info = TopicInfo::from_description(&describe_key("", key, None));
298            assert_eq!(info.verdict, want, "{key}");
299            assert!(!info.note.is_empty(), "{key} must explain itself");
300        }
301        let info = TopicInfo::from_description(&describe_key(
302            "zensight",
303            "other/v1/h-3fa9c2d41b7e/state/x/y",
304            None,
305        ));
306        assert_eq!(info.verdict, TopicVerdict::NotUnderBase);
307        // Partial means partial: nothing below the failure point is invented.
308        assert!(info.origin.is_none() && info.payload_type.is_none());
309        // Loaded-and-empty is a different fact from not-loaded (O4).
310        let empty = SliceSet::default();
311        let info = TopicInfo::from_description(&describe_key(
312            "",
313            "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
314            Some(&empty),
315        ));
316        assert_eq!(info.verdict, TopicVerdict::NoSliceForProducer);
317        // The ladder reached the parse rung, so structural facts ARE present…
318        assert_eq!(info.origin.as_deref(), Some("h-3fa9c2d41b7e"));
319        // …but no registry facts were invented.
320        assert!(info.payload_type.is_none());
321    }
322}