1use std::collections::BTreeMap;
22
23use zenoh::key_expr::keyexpr;
24
25use crate::registry::SliceSet;
26use crate::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.clone()));
194 path.push(SkeletonChunk::Literal(subject.class.clone()));
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.clone()));
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.clone()));
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 fn from_tree(node: &TreeNode) -> NodeStats {
358 NodeStats {
359 count: node.count,
360 bytes: node.bytes,
361 rate_hz: node.rate_hz,
362 subtree_count: node.subtree_count,
363 subtree_bytes: node.subtree_bytes,
364 subtree_rate_hz: node.subtree_rate_hz,
365 subtree_keys: node.subtree_keys,
366 subtree_last_seen: node.subtree_last_seen,
367 }
368 }
369}
370
371#[derive(Debug, Clone)]
373pub struct MergedNode {
374 pub children: BTreeMap<String, MergedNode>,
375 pub status: NodeStatus,
376 pub stats: Option<NodeStats>,
377 pub decl: Option<DeclRef>,
378}
379
380pub fn merge(skeleton: &Skeleton, observed: &KeyTreeSnapshot, watched: &[String]) -> MergedNode {
383 let watched: Vec<&keyexpr> = watched
387 .iter()
388 .filter_map(|w| keyexpr::new(w.as_str()).ok())
389 .collect();
390 let mut path = String::new();
393 merge_nodes(
394 Some(&skeleton.root),
395 Some(&observed.root),
396 &watched,
397 &mut path,
398 )
399}
400
401fn merge_nodes(
402 skel: Option<&SkeletonNode>,
403 obs: Option<&TreeNode>,
404 watched: &[&keyexpr],
405 path: &mut String,
406) -> MergedNode {
407 let evidence = skel.map(|s| s.evidence).unwrap_or_default();
408 let stats = obs.map(NodeStats::from_tree);
409 let covered = is_covered(path, watched);
410 let status = match (stats.is_some(), covered) {
411 (true, true) => NodeStatus::Observed(evidence),
412 (true, false) => NodeStatus::Unwatched(evidence),
413 (false, true) => NodeStatus::WatchedQuiet(evidence),
414 (false, false) => NodeStatus::DeclaredOnly(evidence),
415 };
416
417 let mut names: Vec<&String> = Vec::new();
418 if let Some(s) = skel {
419 names.extend(s.children.keys());
420 }
421 if let Some(o) = obs {
422 names.extend(o.children.keys());
423 }
424 names.sort();
425 names.dedup();
426
427 let mut children = BTreeMap::new();
428 for name in names {
429 let skel_child = skel.and_then(|s| s.children.get(name));
430 let obs_child = obs.and_then(|o| o.children.get(name));
431 let sel_chunk = skel_child
434 .and_then(|c| c.chunk.as_ref())
435 .map(|c| c.selector_chunk())
436 .unwrap_or(name.as_str());
437 let mark = path.len();
438 if !path.is_empty() {
439 path.push('/');
440 }
441 path.push_str(sel_chunk);
442 let child = merge_nodes(skel_child, obs_child, watched, path);
443 path.truncate(mark);
444 children.insert(name.clone(), child);
445 }
446
447 MergedNode {
448 children,
449 status,
450 stats,
451 decl: skel.and_then(|s| s.decl.clone()),
452 }
453}
454
455fn is_covered(prefix: &str, watched: &[&keyexpr]) -> bool {
462 if watched.is_empty() {
463 return false;
464 }
465 if prefix.is_empty() {
466 return true;
467 }
468 thread_local! {
472 static SUBTREE: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
473 }
474 SUBTREE.with(|buf| {
475 let mut buf = buf.borrow_mut();
476 buf.clear();
477 buf.push_str(prefix);
478 buf.push_str("/**");
479 let Ok(node) = keyexpr::new(buf.as_str()) else {
480 return false;
481 };
482 watched.iter().any(|w| w.intersects(node))
483 })
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::stats::StatsTable;
490 use std::time::Instant;
491 use zenkey::slice::{RegistrySlice, SubjectDecl};
492
493 fn subject(path: &str, class: &str) -> SubjectDecl {
494 SubjectDecl {
495 path: path.into(),
496 class: class.into(),
497 type_name: "T".into(),
498 common: None,
499 since: None,
500 description: None,
501 qos: None,
502 ttl_s: None,
503 unit: None,
504 rate: None,
505 cardinality: None,
506 encoding: None,
507 }
508 }
509
510 fn host_slice(name: &str, subjects: Vec<SubjectDecl>) -> RegistrySlice {
511 RegistrySlice {
512 version: "1.0".into(),
513 app: "test".into(),
514 convention: 1,
515 name: name.into(),
516 service_origin: None,
517 description: None,
518 subjects,
519 procedures: vec![],
520 blob: vec![],
521 media: vec![],
522 deprecated: vec![],
523 }
524 }
525
526 #[test]
527 fn skeleton_places_declared_subjects_under_live_origins() {
528 let slices = SliceSet::from_slices(vec![host_slice(
529 "sysinfo",
530 vec![subject("disk/{mount}/used", "telemetry")],
531 )]);
532 let mut roster = BTreeMap::new();
533 roster.insert("h-3fa9c2d41b7e".to_string(), vec!["sysinfo".to_string()]);
534 let skel = Skeleton::build("", &slices, &roster, None);
535
536 let node = &skel.root.children["v1"].children["h-3fa9c2d41b7e"].children["telemetry"]
537 .children["sysinfo"]
538 .children["disk"]
539 .children["{mount}"]
540 .children["used"];
541 assert!(node.evidence.declared && node.evidence.alive);
542 assert_eq!(node.decl.as_ref().unwrap().type_name, "T");
543 assert_eq!(
544 skel.coverage.admin_entities, None,
545 "admin not asked is None, not zero (O4)"
546 );
547 }
548
549 #[test]
552 fn skeleton_keeps_unplaced_producers_symbolic() {
553 let slices = SliceSet::from_slices(vec![host_slice(
554 "sysinfo",
555 vec![subject("health", "state")],
556 )]);
557 let skel = Skeleton::build("", &slices, &BTreeMap::new(), None);
558 let origin = &skel.root.children["v1"].children["{origin}"];
559 assert!(origin.evidence.declared && !origin.evidence.alive);
560 assert!(
561 origin.children["state"].children["sysinfo"].children["health"]
562 .evidence
563 .declared
564 );
565 }
566
567 #[test]
569 fn skeleton_places_service_origins_without_a_producer_chunk() {
570 let mut slice = host_slice("catalog", vec![subject("entity/{id}", "state")]);
571 slice.service_origin = Some("@catalog".into());
572 let slices = SliceSet::from_slices(vec![slice]);
573 let skel = Skeleton::build("", &slices, &BTreeMap::new(), None);
574 let state = &skel.root.children["v1"].children["@catalog"].children["state"];
575 assert!(state.children["entity"].children["{id}"].evidence.declared);
576 }
577
578 #[test]
581 fn skeleton_admin_evidence_is_concrete_only() {
582 let entities = crate::DeclaredEntities {
583 entities: vec![
584 crate::DeclaredEntity {
585 kind: crate::EntityKind::Publisher,
586 keyexpr: "v1/h-aabbccddeeff/telemetry/x/m".into(),
587 node_zid: "z".into(),
588 sources: serde_json::Value::Null,
589 },
590 crate::DeclaredEntity {
591 kind: crate::EntityKind::Subscriber,
592 keyexpr: "v1/*/state/**".into(),
593 node_zid: "z".into(),
594 sources: serde_json::Value::Null,
595 },
596 ],
597 };
598 let skel = Skeleton::build("", &SliceSet::default(), &BTreeMap::new(), Some(&entities));
599 assert_eq!(skel.coverage.admin_entities, Some(2));
600 let concrete = &skel.root.children["v1"].children["h-aabbccddeeff"];
601 assert!(concrete.evidence.admin);
602 assert_eq!(skel.root.children["v1"].children.len(), 1);
605 }
606
607 #[test]
610 fn merge_produces_all_four_statuses() {
611 let slices = SliceSet::from_slices(vec![host_slice(
613 "sysinfo",
614 vec![subject("cpu", "telemetry"), subject("mem", "telemetry")],
615 )]);
616 let mut roster = BTreeMap::new();
617 roster.insert("h-3fa9c2d41b7e".to_string(), vec!["sysinfo".to_string()]);
618 let skel = Skeleton::build("", &slices, &roster, None);
619
620 let mut stats = StatsTable::new();
622 let now = Instant::now();
623 stats.record(
624 "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
625 4,
626 None,
627 now,
628 None,
629 );
630 stats.record("demo/foreign", 4, None, now, None);
631 let observed = KeyTreeSnapshot::build(&stats);
632
633 let watched = vec!["v1/h-3fa9c2d41b7e/telemetry/**".to_string()];
634 let merged = merge(&skel, &observed, &watched);
635
636 let sysinfo = &merged.children["v1"].children["h-3fa9c2d41b7e"].children["telemetry"]
637 .children["sysinfo"];
638 assert!(matches!(
639 sysinfo.children["cpu"].status,
640 NodeStatus::Observed(_)
641 ));
642 assert!(
643 matches!(sysinfo.children["mem"].status, NodeStatus::WatchedQuiet(_)),
644 "declared, covered, no traffic — quiet is an observation"
645 );
646 assert!(matches!(
647 merged.children["demo"].children["foreign"].status,
648 NodeStatus::Unwatched(_)
649 ));
650
651 let merged = merge(&skel, &KeyTreeSnapshot::default(), &[]);
653 assert!(matches!(
654 merged.children["v1"].status,
655 NodeStatus::DeclaredOnly(_)
656 ));
657 }
658
659 #[test]
660 fn instance_suffixes_place_under_their_base_producer() {
661 let slices = SliceSet::from_slices(vec![host_slice(
662 "snmp",
663 vec![subject("if/{iface}/in", "telemetry")],
664 )]);
665 let mut roster = BTreeMap::new();
666 roster.insert("h-aabbccddeeff".to_string(), vec!["snmp-2".to_string()]);
667 let skel = Skeleton::build("", &slices, &roster, None);
668 assert!(
669 skel.root.children["v1"].children["h-aabbccddeeff"].children["telemetry"].children
670 ["snmp"]
671 .evidence
672 .alive
673 );
674 }
675}