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")]
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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
153#[serde(rename_all = "snake_case")]
154pub enum TopicVerdict {
155 Registered,
157 Unregistered,
159 NoSliceForProducer,
161 NotADataClass,
164 NotV1,
166 NotUnderBase,
168 RegistryNotLoaded,
171}
172
173impl TopicInfo {
174 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 info.cardinality = s.cardinality;
229 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 #[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 assert!(info.origin.is_none() && info.payload_type.is_none());
303 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 assert_eq!(info.origin.as_deref(), Some("h-3fa9c2d41b7e"));
313 assert!(info.payload_type.is_none());
315 }
316}