1use 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 pub open_ended: bool,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub since: Option<String>,
26 #[serde(skip_serializing_if = "std::ops::Not::not")]
29 pub deprecated: bool,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub deprecated_since: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub replaced_by: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
40 pub cardinality: Option<i64>,
41 #[serde(skip_serializing_if = "Option::is_none")]
44 pub budget: Option<BudgetCell>,
45}
46
47#[derive(Debug, Clone, Serialize)]
57pub struct BudgetCell {
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub declared: Option<i64>,
61 pub observed: usize,
63 pub origins: usize,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub worst_origin: Option<String>,
68 pub worst_observed: usize,
70 #[serde(skip_serializing_if = "Option::is_none")]
73 pub exempt: Option<String>,
74 pub over: bool,
76 #[serde(skip_serializing_if = "Vec::is_empty")]
78 pub examples: Vec<String>,
79}
80
81#[derive(Debug, Clone, Serialize)]
86pub struct BudgetWindow {
87 pub window_s: f64,
88 pub scopes: Vec<String>,
90 pub keys: usize,
92 pub evicted: u64,
95}
96
97#[derive(Debug, Clone, Serialize)]
98pub struct TopicList {
99 pub subjects: Vec<TopicRow>,
100 #[serde(skip_serializing_if = "Option::is_none")]
103 pub budget: Option<BudgetWindow>,
104}
105
106#[derive(Debug, Clone, Serialize)]
114pub struct TopicInfo {
115 pub key: String,
116 pub verdict: TopicVerdict,
118 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")]
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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
157#[serde(rename_all = "snake_case")]
158pub enum TopicVerdict {
159 Registered,
161 Unregistered,
163 NoSliceForProducer,
165 NotADataClass,
168 NotV1,
170 NotUnderBase,
172 RegistryNotLoaded,
175}
176
177impl TopicInfo {
178 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 info.cardinality = s.cardinality;
235 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 #[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 assert!(info.origin.is_none() && info.payload_type.is_none());
309 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 assert_eq!(info.origin.as_deref(), Some("h-3fa9c2d41b7e"));
319 assert!(info.payload_type.is_none());
321 }
322}