1use std::collections::BTreeMap;
22
23use zenoh::key_expr::keyexpr;
24
25use crate::model::registry::SliceSet;
26use crate::model::tree::{KeyTreeSnapshot, TreeNode};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum SkeletonChunk {
35 Literal(String),
36 Var(String),
38 Rest(String),
40}
41
42impl SkeletonChunk {
43 fn parse(chunk: &str) -> SkeletonChunk {
44 if let Some(inner) = chunk.strip_prefix('{').and_then(|c| c.strip_suffix("...}")) {
45 SkeletonChunk::Rest(inner.to_string())
46 } else if let Some(inner) = chunk.strip_prefix('{').and_then(|c| c.strip_suffix('}')) {
47 SkeletonChunk::Var(inner.to_string())
48 } else {
49 SkeletonChunk::Literal(chunk.to_string())
50 }
51 }
52
53 pub fn display(&self) -> String {
55 match self {
56 SkeletonChunk::Literal(s) => s.clone(),
57 SkeletonChunk::Var(v) => format!("{{{v}}}"),
58 SkeletonChunk::Rest(v) => format!("{{{v}...}}"),
59 }
60 }
61
62 fn selector_chunk(&self) -> &str {
65 match self {
66 SkeletonChunk::Literal(s) => s,
67 SkeletonChunk::Var(_) => "*",
68 SkeletonChunk::Rest(_) => "**",
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
76pub struct Evidence {
77 pub declared: bool,
79 pub alive: bool,
81 pub admin: bool,
83}
84
85impl Evidence {
86 fn merge(self, other: Evidence) -> Evidence {
87 Evidence {
88 declared: self.declared || other.declared,
89 alive: self.alive || other.alive,
90 admin: self.admin || other.admin,
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct DeclRef {
98 pub producer: String,
99 pub path: String,
101 pub type_name: String,
102}
103
104#[derive(Debug, Clone, Default)]
105pub struct SkeletonNode {
106 pub chunk: Option<SkeletonChunk>,
107 pub children: BTreeMap<String, SkeletonNode>,
108 pub evidence: Evidence,
109 pub decl: Option<DeclRef>,
111}
112
113impl SkeletonNode {
114 fn insert(&mut self, chunks: &[SkeletonChunk], evidence: Evidence, decl: Option<DeclRef>) {
115 self.evidence = self.evidence.merge(evidence);
116 let Some((first, rest)) = chunks.split_first() else {
117 if decl.is_some() {
118 self.decl = decl;
119 }
120 return;
121 };
122 let child = self
123 .children
124 .entry(first.display())
125 .or_insert_with(|| SkeletonNode {
126 chunk: Some(first.clone()),
127 ..SkeletonNode::default()
128 });
129 child.insert(rest, evidence, decl);
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct SkeletonCoverage {
136 pub slices: usize,
137 pub roster_origins: usize,
138 pub admin_entities: Option<usize>,
141}
142
143#[derive(Debug, Clone)]
144pub struct Skeleton {
145 pub root: SkeletonNode,
146 pub coverage: SkeletonCoverage,
147}
148
149impl Skeleton {
150 pub fn build(
165 base: &str,
166 slices: &SliceSet,
167 roster: &BTreeMap<String, Vec<String>>,
168 admin: Option<&crate::DeclaredEntities>,
169 ) -> Skeleton {
170 let mut root = SkeletonNode::default();
171 let base_chunks: Vec<SkeletonChunk> = if base.is_empty() {
172 Vec::new()
173 } else {
174 base.split('/')
175 .map(|c| SkeletonChunk::Literal(c.to_string()))
176 .collect()
177 };
178
179 for slice in slices.slices() {
180 for subject in &slice.subjects {
181 let decl = DeclRef {
182 producer: slice.name.clone(),
183 path: subject.path.clone(),
184 type_name: subject.type_name.clone(),
185 };
186 let tail: Vec<SkeletonChunk> =
187 subject.path.split('/').map(SkeletonChunk::parse).collect();
188
189 if let Some(origin) = &slice.service_origin {
190 let mut path = base_chunks.clone();
192 path.push(SkeletonChunk::Literal("v1".into()));
193 path.push(SkeletonChunk::Literal(origin.token().to_string()));
194 path.push(SkeletonChunk::Literal(subject.class.token().to_string()));
195 path.extend(tail.clone());
196 root.insert(
197 &path,
198 Evidence {
199 declared: true,
200 ..Evidence::default()
201 },
202 Some(decl.clone()),
203 );
204 continue;
205 }
206
207 let live: Vec<&String> = roster
209 .iter()
210 .filter(|(_, producers)| {
211 producers
212 .iter()
213 .any(|p| p == &slice.name || instance_base(p) == slice.name)
214 })
215 .map(|(origin, _)| origin)
216 .collect();
217 if live.is_empty() {
218 let mut path = base_chunks.clone();
221 path.push(SkeletonChunk::Literal("v1".into()));
222 path.push(SkeletonChunk::Var("origin".into()));
223 path.push(SkeletonChunk::Literal(subject.class.token().to_string()));
224 path.push(SkeletonChunk::Literal(slice.name.clone()));
225 path.extend(tail.clone());
226 root.insert(
227 &path,
228 Evidence {
229 declared: true,
230 ..Evidence::default()
231 },
232 Some(decl.clone()),
233 );
234 } else {
235 for origin in live {
236 let mut path = base_chunks.clone();
237 path.push(SkeletonChunk::Literal("v1".into()));
238 path.push(SkeletonChunk::Literal(origin.clone()));
239 path.push(SkeletonChunk::Literal(subject.class.token().to_string()));
240 path.push(SkeletonChunk::Literal(slice.name.clone()));
241 path.extend(tail.clone());
242 root.insert(
243 &path,
244 Evidence {
245 declared: true,
246 alive: true,
247 ..Evidence::default()
248 },
249 Some(decl.clone()),
250 );
251 }
252 }
253 }
254 }
255
256 let mut admin_count = None;
257 if let Some(entities) = admin {
258 admin_count = Some(entities.entities.len());
259 for e in &entities.entities {
260 if e.keyexpr.contains('*') {
261 continue; }
263 let path: Vec<SkeletonChunk> = e
264 .keyexpr
265 .split('/')
266 .map(|c| SkeletonChunk::Literal(c.to_string()))
267 .collect();
268 root.insert(
269 &path,
270 Evidence {
271 admin: true,
272 ..Evidence::default()
273 },
274 None,
275 );
276 }
277 }
278
279 Skeleton {
280 root,
281 coverage: SkeletonCoverage {
282 slices: slices.slices().len(),
283 roster_origins: roster.len(),
284 admin_entities: admin_count,
285 },
286 }
287 }
288}
289
290fn instance_base(producer: &str) -> &str {
292 match producer.rsplit_once('-') {
293 Some((name, suffix))
294 if !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()) =>
295 {
296 name
297 }
298 _ => producer,
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum NodeStatus {
318 DeclaredOnly(Evidence),
320 WatchedQuiet(Evidence),
322 Observed(Evidence),
324 Unwatched(Evidence),
326}
327
328impl NodeStatus {
329 pub fn evidence(self) -> Evidence {
330 match self {
331 NodeStatus::DeclaredOnly(e)
332 | NodeStatus::WatchedQuiet(e)
333 | NodeStatus::Observed(e)
334 | NodeStatus::Unwatched(e) => e,
335 }
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq)]
342pub struct NodeStats {
343 pub count: u64,
344 pub bytes: u64,
345 pub rate_hz: f64,
346 pub subtree_count: u64,
347 pub subtree_bytes: u64,
348 pub subtree_rate_hz: f64,
349 pub subtree_keys: usize,
350 pub subtree_last_seen: Option<std::time::Instant>,
354}
355
356impl NodeStats {
357 pub fn from_tree(node: &TreeNode) -> NodeStats {
365 NodeStats {
366 count: node.count,
367 bytes: node.bytes,
368 rate_hz: node.rate_hz,
369 subtree_count: node.subtree_count,
370 subtree_bytes: node.subtree_bytes,
371 subtree_rate_hz: node.subtree_rate_hz,
372 subtree_keys: node.subtree_keys,
373 subtree_last_seen: node.subtree_last_seen,
374 }
375 }
376}
377
378#[derive(Debug, Clone)]
380pub struct MergedNode {
381 pub children: BTreeMap<String, MergedNode>,
382 pub status: NodeStatus,
383 pub stats: Option<NodeStats>,
384 pub decl: Option<DeclRef>,
385}
386
387pub fn merge(skeleton: &Skeleton, observed: &KeyTreeSnapshot, watched: &[String]) -> MergedNode {
390 let watched: Vec<&keyexpr> = watched
394 .iter()
395 .filter_map(|w| keyexpr::new(w.as_str()).ok())
396 .collect();
397 let mut path = String::new();
400 merge_nodes(
401 Some(&skeleton.root),
402 Some(&observed.root),
403 &watched,
404 &mut path,
405 )
406}
407
408fn merge_nodes(
409 skel: Option<&SkeletonNode>,
410 obs: Option<&TreeNode>,
411 watched: &[&keyexpr],
412 path: &mut String,
413) -> MergedNode {
414 let evidence = skel.map(|s| s.evidence).unwrap_or_default();
415 let stats = obs.map(NodeStats::from_tree);
416 let covered = is_covered(path, watched);
417 let status = match (stats.is_some(), covered) {
418 (true, true) => NodeStatus::Observed(evidence),
419 (true, false) => NodeStatus::Unwatched(evidence),
420 (false, true) => NodeStatus::WatchedQuiet(evidence),
421 (false, false) => NodeStatus::DeclaredOnly(evidence),
422 };
423
424 let mut names: Vec<&String> = Vec::new();
425 if let Some(s) = skel {
426 names.extend(s.children.keys());
427 }
428 if let Some(o) = obs {
429 names.extend(o.children.keys());
430 }
431 names.sort();
432 names.dedup();
433
434 let mut children = BTreeMap::new();
435 for name in names {
436 let skel_child = skel.and_then(|s| s.children.get(name));
437 let obs_child = obs.and_then(|o| o.children.get(name));
438 let sel_chunk = skel_child
441 .and_then(|c| c.chunk.as_ref())
442 .map(|c| c.selector_chunk())
443 .unwrap_or(name.as_str());
444 let mark = path.len();
445 if !path.is_empty() {
446 path.push('/');
447 }
448 path.push_str(sel_chunk);
449 let child = merge_nodes(skel_child, obs_child, watched, path);
450 path.truncate(mark);
451 children.insert(name.clone(), child);
452 }
453
454 MergedNode {
455 children,
456 status,
457 stats,
458 decl: skel.and_then(|s| s.decl.clone()),
459 }
460}
461
462fn is_covered(prefix: &str, watched: &[&keyexpr]) -> bool {
469 if watched.is_empty() {
470 return false;
471 }
472 if prefix.is_empty() {
473 return true;
474 }
475 thread_local! {
479 static SUBTREE: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
480 }
481 SUBTREE.with(|buf| {
482 let mut buf = buf.borrow_mut();
483 buf.clear();
484 buf.push_str(prefix);
485 buf.push_str("/**");
486 let Ok(node) = keyexpr::new(buf.as_str()) else {
487 return false;
488 };
489 watched.iter().any(|w| w.intersects(node))
490 })
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use crate::model::stats::StatsTable;
497 use std::time::Instant;
498 use zenkey::slice::{RegistrySlice, SubjectDecl};
499
500 fn subject(path: &str, class: &str) -> SubjectDecl {
501 let mut d = SubjectDecl::new(path, zenkey::Declared::parse(class));
502 d.type_name = "T".into();
503 d
504 }
505
506 fn host_slice(name: &str, subjects: Vec<SubjectDecl>) -> RegistrySlice {
507 let mut slice = RegistrySlice::new("1.0", "test", name);
508 slice.subjects = subjects;
509 slice
510 }
511
512 #[test]
513 fn skeleton_places_declared_subjects_under_live_origins() {
514 let slices = SliceSet::from_slices(vec![host_slice(
515 "sysinfo",
516 vec![subject("disk/{mount}/used", "telemetry")],
517 )]);
518 let mut roster = BTreeMap::new();
519 roster.insert("h-3fa9c2d41b7e".to_string(), vec!["sysinfo".to_string()]);
520 let skel = Skeleton::build("", &slices, &roster, None);
521
522 let node = &skel.root.children["v1"].children["h-3fa9c2d41b7e"].children["telemetry"]
523 .children["sysinfo"]
524 .children["disk"]
525 .children["{mount}"]
526 .children["used"];
527 assert!(node.evidence.declared && node.evidence.alive);
528 assert_eq!(node.decl.as_ref().unwrap().type_name, "T");
529 assert_eq!(
530 skel.coverage.admin_entities, None,
531 "admin not asked is None, not zero (O4)"
532 );
533 }
534
535 #[test]
538 fn skeleton_keeps_unplaced_producers_symbolic() {
539 let slices = SliceSet::from_slices(vec![host_slice(
540 "sysinfo",
541 vec![subject("health", "state")],
542 )]);
543 let skel = Skeleton::build("", &slices, &BTreeMap::new(), None);
544 let origin = &skel.root.children["v1"].children["{origin}"];
545 assert!(origin.evidence.declared && !origin.evidence.alive);
546 assert!(
547 origin.children["state"].children["sysinfo"].children["health"]
548 .evidence
549 .declared
550 );
551 }
552
553 #[test]
555 fn skeleton_places_service_origins_without_a_producer_chunk() {
556 let mut slice = host_slice("catalog", vec![subject("entity/{id}", "state")]);
557 slice.service_origin = Some(zenkey::Declared::parse("@catalog"));
558 let slices = SliceSet::from_slices(vec![slice]);
559 let skel = Skeleton::build("", &slices, &BTreeMap::new(), None);
560 let state = &skel.root.children["v1"].children["@catalog"].children["state"];
561 assert!(state.children["entity"].children["{id}"].evidence.declared);
562 }
563
564 #[test]
567 fn skeleton_admin_evidence_is_concrete_only() {
568 let entities = crate::DeclaredEntities {
569 entities: vec![
570 crate::DeclaredEntity {
571 kind: crate::EntityKind::Publisher,
572 keyexpr: "v1/h-aabbccddeeff/telemetry/x/m".into(),
573 node_zid: "z".into(),
574 sources: serde_json::Value::Null,
575 },
576 crate::DeclaredEntity {
577 kind: crate::EntityKind::Subscriber,
578 keyexpr: "v1/*/state/**".into(),
579 node_zid: "z".into(),
580 sources: serde_json::Value::Null,
581 },
582 ],
583 };
584 let skel = Skeleton::build("", &SliceSet::default(), &BTreeMap::new(), Some(&entities));
585 assert_eq!(skel.coverage.admin_entities, Some(2));
586 let concrete = &skel.root.children["v1"].children["h-aabbccddeeff"];
587 assert!(concrete.evidence.admin);
588 assert_eq!(skel.root.children["v1"].children.len(), 1);
591 }
592
593 #[test]
596 fn merge_produces_all_four_statuses() {
597 let slices = SliceSet::from_slices(vec![host_slice(
599 "sysinfo",
600 vec![subject("cpu", "telemetry"), subject("mem", "telemetry")],
601 )]);
602 let mut roster = BTreeMap::new();
603 roster.insert("h-3fa9c2d41b7e".to_string(), vec!["sysinfo".to_string()]);
604 let skel = Skeleton::build("", &slices, &roster, None);
605
606 let mut stats = StatsTable::new();
608 let now = Instant::now();
609 stats.record(
610 "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
611 4,
612 None,
613 now,
614 None,
615 None,
616 );
617 stats.record("demo/foreign", 4, None, now, None, None);
618 let observed = KeyTreeSnapshot::build(&stats);
619
620 let watched = vec!["v1/h-3fa9c2d41b7e/telemetry/**".to_string()];
621 let merged = merge(&skel, &observed, &watched);
622
623 let sysinfo = &merged.children["v1"].children["h-3fa9c2d41b7e"].children["telemetry"]
624 .children["sysinfo"];
625 assert!(matches!(
626 sysinfo.children["cpu"].status,
627 NodeStatus::Observed(_)
628 ));
629 assert!(
630 matches!(sysinfo.children["mem"].status, NodeStatus::WatchedQuiet(_)),
631 "declared, covered, no traffic — quiet is an observation"
632 );
633 assert!(matches!(
634 merged.children["demo"].children["foreign"].status,
635 NodeStatus::Unwatched(_)
636 ));
637
638 let merged = merge(&skel, &KeyTreeSnapshot::default(), &[]);
640 assert!(matches!(
641 merged.children["v1"].status,
642 NodeStatus::DeclaredOnly(_)
643 ));
644 }
645
646 #[test]
647 fn instance_suffixes_place_under_their_base_producer() {
648 let slices = SliceSet::from_slices(vec![host_slice(
649 "snmp",
650 vec![subject("if/{iface}/in", "telemetry")],
651 )]);
652 let mut roster = BTreeMap::new();
653 roster.insert("h-aabbccddeeff".to_string(), vec!["snmp-2".to_string()]);
654 let skel = Skeleton::build("", &slices, &roster, None);
655 assert!(
656 skel.root.children["v1"].children["h-aabbccddeeff"].children["telemetry"].children
657 ["snmp"]
658 .evidence
659 .alive
660 );
661 }
662}