use crate::model::facts::{KeyDescription, KeyShape, Registration};
use serde::Serialize;
use std::collections::BTreeMap;
use zenkey::RateClass;
#[derive(Debug, Clone, Serialize)]
pub struct TopicRow {
pub producer: String,
pub registry_version: String,
pub class: String,
pub path: String,
pub type_name: String,
pub open_ended: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub since: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub deprecated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub deprecated_since: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub replaced_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cardinality: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget: Option<BudgetCell>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BudgetCell {
#[serde(skip_serializing_if = "Option::is_none")]
pub declared: Option<i64>,
pub observed: usize,
pub origins: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub worst_origin: Option<String>,
pub worst_observed: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub exempt: Option<String>,
pub over: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BudgetWindow {
pub window_s: f64,
pub scopes: Vec<String>,
pub keys: usize,
pub evicted: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct TopicList {
pub subjects: Vec<TopicRow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget: Option<BudgetWindow>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TopicInfo {
pub key: String,
pub verdict: TopicVerdict,
pub note: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub producer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
pub variables: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub qos: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl_s: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rate: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cardinality: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub encoding: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub since: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TopicVerdict {
Registered,
Unregistered,
NoSliceForProducer,
NotADataClass,
NotV1,
NotUnderBase,
RegistryNotLoaded,
}
impl TopicInfo {
pub fn from_description(d: &KeyDescription) -> TopicInfo {
let mut info = TopicInfo {
key: d.key.clone(),
verdict: TopicVerdict::NotV1,
note: String::new(),
origin: None,
producer: None,
class: None,
subject: None,
variables: BTreeMap::new(),
payload_type: None,
unit: None,
qos: None,
ttl_s: None,
rate: None,
cardinality: None,
encoding: None,
since: None,
description: None,
};
match &d.facts.shape {
KeyShape::NotUnderBase => {
info.verdict = TopicVerdict::NotUnderBase;
info.note = "under a different deployment base than the configured one \
(RFC 03 §1.1); `zenctl base list` discovers the bases in use"
.into();
return info;
}
KeyShape::Unparsed { reason } => {
info.verdict = TopicVerdict::NotV1;
info.note = format!(
"not a keyspace-v2 key — a fact, not an error (RFC 09 §5.1 O1): {reason}"
);
return info;
}
KeyShape::V1(v) => {
info.origin = Some(v.origin.clone());
info.class = Some(v.class.clone());
info.producer = v.producer.clone();
}
}
match &d.facts.registration {
Registration::Registered(s) => {
info.verdict = TopicVerdict::Registered;
info.subject = Some(s.path.clone());
info.variables = s.vars.iter().cloned().collect();
info.payload_type = Some(s.type_name.clone());
info.unit = s.unit.clone();
info.qos = s.qos.as_ref().map(|q| q.token().to_string());
info.encoding = s.encoding.as_ref().map(|e| e.as_encoding_str().to_string());
info.ttl_s = s.ttl_s;
info.cardinality = s.cardinality;
info.rate = s.rate.as_ref().map(RateClass::token);
info.since = s.since.clone();
info.description = s.description.clone();
}
Registration::Unregistered => {
info.verdict = TopicVerdict::Unregistered;
info.note = "parses as a v1 data key, but the producer's slice does not \
declare this subject — for a conforming producer, a subject \
that is not registered does not exist (RFC 08)"
.into();
}
Registration::NoSliceForProducer => {
info.verdict = TopicVerdict::NoSliceForProducer;
info.note = "no loaded registry slice covers this producer — `--registry \
<dir>` supplies slices offline; on-bus they come from \
introspect (RFC 08 §6)"
.into();
}
Registration::Unknown => {
info.verdict = TopicVerdict::RegistryNotLoaded;
info.note = "no registry loaded — \"not asked\" is not \"answered no\" \
(RFC 09 §5.1 O4)"
.into();
}
Registration::NotApplicable => {
info.verdict = TopicVerdict::NotADataClass;
info.note = "a verbatim plane, not a data class — there is no [[subject]] \
surface to describe (RFC 03 §1.4)"
.into();
}
}
info
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::facts::describe_key;
use crate::model::registry::SliceSet;
#[test]
fn topic_info_is_partial_never_absent() {
let cases = [
("demo/example/foo", TopicVerdict::NotV1),
(
"v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect",
TopicVerdict::NotADataClass,
),
(
"v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
TopicVerdict::RegistryNotLoaded,
),
];
for (key, want) in cases {
let info = TopicInfo::from_description(&describe_key("", key, None));
assert_eq!(info.verdict, want, "{key}");
assert!(!info.note.is_empty(), "{key} must explain itself");
}
let info = TopicInfo::from_description(&describe_key(
"zensight",
"other/v1/h-3fa9c2d41b7e/state/x/y",
None,
));
assert_eq!(info.verdict, TopicVerdict::NotUnderBase);
assert!(info.origin.is_none() && info.payload_type.is_none());
let empty = SliceSet::default();
let info = TopicInfo::from_description(&describe_key(
"",
"v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
Some(&empty),
));
assert_eq!(info.verdict, TopicVerdict::NoSliceForProducer);
assert_eq!(info.origin.as_deref(), Some("h-3fa9c2d41b7e"));
assert!(info.payload_type.is_none());
}
}