1use zenoh::key_expr::keyexpr;
16
17use crate::report::{
18 Attribution, ConsumerRow, DeclaredEntities, DeprecationFact, EntityKind, OriginAttachment,
19 Relation, TopologyReport,
20};
21
22pub fn join_consumers(
42 target: &str,
43 declared: &DeclaredEntities,
44 attachments: &[OriginAttachment],
45 topology: Option<&TopologyReport>,
46 self_zid: &str,
47) -> Vec<ConsumerRow> {
48 let Ok(target_ke) = keyexpr::new(target) else {
49 return Vec::new();
50 };
51 let base_tree = base_tree_of(target);
52 let base_ke = keyexpr::new(base_tree.as_str()).ok();
53
54 let mut rows: Vec<ConsumerRow> = Vec::new();
55 for entity in &declared.entities {
56 if !matches!(entity.kind, EntityKind::Subscriber | EntityKind::Querier) {
57 continue;
58 }
59 let Ok(declared_ke) = keyexpr::new(entity.keyexpr.as_str()) else {
60 continue;
63 };
64 let Some(relation) = relate(target_ke, declared_ke, base_ke) else {
65 continue;
66 };
67 let zids = crate::bus::admin::source_zids(&entity.sources);
68 let attributed: Vec<(String, Attribution)> = if zids.is_empty() {
69 vec![(entity.node_zid.clone(), Attribution::ReportedOnly)]
70 } else {
71 zids.into_iter()
72 .map(|z| (z, Attribution::Session))
73 .collect()
74 };
75 for (zid, attribution) in attributed {
76 if rows
80 .iter()
81 .any(|r| r.zid == zid && r.kind == entity.kind && r.keyexpr == entity.keyexpr)
82 {
83 continue;
84 }
85 let mut origins: Vec<String> = attachments
86 .iter()
87 .filter(|a| a.session_zid.as_deref() == Some(zid.as_str()))
88 .map(|a| a.origin.clone())
89 .collect();
90 origins.sort();
91 origins.dedup();
92 let whatami = topology
93 .and_then(|t| t.nodes.iter().find(|n| n.zid == zid))
94 .map(|n| n.whatami.clone());
95 rows.push(ConsumerRow {
96 is_self: zid == self_zid,
97 zid,
98 whatami,
99 origins,
100 attribution,
101 kind: entity.kind,
102 keyexpr: entity.keyexpr.clone(),
103 relation,
104 total_wildcard: relation == Relation::Total,
105 });
106 }
107 }
108 rows.sort_by(|a, b| {
109 a.relation
110 .cmp(&b.relation)
111 .then_with(|| a.keyexpr.cmp(&b.keyexpr))
112 .then_with(|| a.zid.cmp(&b.zid))
113 });
114 rows
115}
116
117fn relate(target: &keyexpr, declared: &keyexpr, base: Option<&keyexpr>) -> Option<Relation> {
120 if !declared.intersects(target) {
121 return None;
122 }
123 if base.is_some_and(|b| declared.includes(b)) {
124 return Some(Relation::Total);
125 }
126 let wider = declared.includes(target);
127 let narrower = target.includes(declared);
128 Some(match (wider, narrower) {
129 (true, true) => Relation::Exact,
130 (false, true) => Relation::Narrower,
131 (true, false) => Relation::Wider,
132 (false, false) => Relation::Intersects,
133 })
134}
135
136fn base_tree_of(target: &str) -> String {
141 let chunks: Vec<&str> = target.split('/').collect();
142 match chunks.iter().position(|c| *c == "v1") {
143 Some(i) => {
144 let mut prefix = chunks[..=i].join("/");
145 prefix.push_str("/**");
146 prefix
147 }
148 None => "**".to_string(),
149 }
150}
151
152pub fn declaring_sessions(target: &str, declared: &DeclaredEntities, kind: EntityKind) -> usize {
157 let Ok(target_ke) = keyexpr::new(target) else {
158 return 0;
159 };
160 let mut zids: Vec<String> = Vec::new();
161 for entity in declared.entities.iter().filter(|e| e.kind == kind) {
162 let Ok(declared_ke) = keyexpr::new(entity.keyexpr.as_str()) else {
163 continue;
164 };
165 if !declared_ke.intersects(target_ke) {
166 continue;
167 }
168 let sources = crate::bus::admin::source_zids(&entity.sources);
169 if sources.is_empty() {
170 zids.push(entity.node_zid.clone());
171 } else {
172 zids.extend(sources);
173 }
174 }
175 zids.sort();
176 zids.dedup();
177 zids.len()
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct SubjectTarget {
184 pub class: String,
186 pub selector: String,
189 pub deprecated: Option<DeprecationFact>,
190}
191
192pub fn subject_target(
200 slices: &crate::SliceSet,
201 base: &str,
202 producer: &str,
203 path: &str,
204) -> Option<SubjectTarget> {
205 let slice = slices.get(producer)?;
206 let deprecated = slice
207 .deprecated
208 .iter()
209 .find(|d| d.path == path && d.kind == zenkey::slice::DeprecatedKind::Subject)
210 .map(|d| DeprecationFact {
211 since: d.since.clone(),
212 replaced_by: d.replaced_by.clone(),
213 });
214 let subject = slice.subjects.iter().find(|s| s.path == path);
215 let class = match subject {
216 Some(s) => s.class.token().to_string(),
217 None => {
218 deprecated.as_ref()?;
219 "*".to_string()
220 }
221 };
222 let tail = zenkey::pattern::SubjectPattern::parse(path)
223 .ok()?
224 .selector_tail();
225 let relative = match &slice.service_origin {
230 Some(origin) => format!("v1/{origin}/{class}/{tail}"),
231 None => format!("v1/*/{class}/{}/{tail}", slice.name),
232 };
233 Some(SubjectTarget {
234 class,
235 selector: zenkey::grammar::with_base(base, relative),
236 deprecated,
237 })
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::report::{DeclaredEntity, TopologyNode};
244 use serde_json::json;
245
246 fn entity(
247 kind: EntityKind,
248 keyexpr: &str,
249 node: &str,
250 sources: serde_json::Value,
251 ) -> DeclaredEntity {
252 DeclaredEntity {
253 kind,
254 keyexpr: keyexpr.into(),
255 node_zid: node.into(),
256 sources,
257 }
258 }
259
260 fn peers(zids: &[&str]) -> serde_json::Value {
261 json!({ "routers": [], "peers": zids, "clients": [] })
262 }
263
264 const TARGET: &str = "v1/h-cccccccccccc/state/demo/**";
265
266 #[test]
269 fn a_narrow_and_a_total_subscriber_rank_by_relation() {
270 let declared = DeclaredEntities {
271 entities: vec![
272 entity(
273 EntityKind::Subscriber,
274 "**",
275 "router1",
276 peers(&["zid-wide"]),
277 ),
278 entity(
279 EntityKind::Subscriber,
280 "v1/h-cccccccccccc/state/demo/health",
281 "router1",
282 peers(&["zid-narrow"]),
283 ),
284 entity(
286 EntityKind::Publisher,
287 "v1/h-cccccccccccc/state/demo/health",
288 "router1",
289 peers(&["zid-pub"]),
290 ),
291 entity(
293 EntityKind::Subscriber,
294 "v1/h-dddddddddddd/state/demo/health",
295 "router1",
296 peers(&["zid-other"]),
297 ),
298 ],
299 };
300 let attachments = vec![OriginAttachment {
301 origin: "h-cccccccccccc".into(),
302 session_zid: Some("zid-narrow".into()),
303 reporter_zid: "router1".into(),
304 token_key: "v1/h-cccccccccccc/state/demo/alive".into(),
305 }];
306 let rows = join_consumers(TARGET, &declared, &attachments, None, "me");
307 assert_eq!(rows.len(), 2, "{rows:?}");
308 assert_eq!(rows[0].relation, Relation::Narrower);
309 assert_eq!(rows[0].zid, "zid-narrow");
310 assert_eq!(rows[0].origins, ["h-cccccccccccc"]);
311 assert_eq!(rows[0].attribution, Attribution::Session);
312 assert!(!rows[0].total_wildcard);
313 assert_eq!(rows[1].relation, Relation::Total);
314 assert!(rows[1].total_wildcard);
315 assert!(rows[1].origins.is_empty(), "session only, unattributed");
316 }
317
318 #[test]
321 fn every_relation_is_reachable() {
322 let declared = DeclaredEntities {
323 entities: vec![
324 entity(EntityKind::Subscriber, TARGET, "n", peers(&["exact"])),
325 entity(
326 EntityKind::Querier,
327 "v1/*/state/demo/**",
328 "n",
329 peers(&["wider"]),
330 ),
331 entity(
332 EntityKind::Subscriber,
333 "v1/h-cccccccccccc/*/demo/health",
334 "n",
335 peers(&["intersects"]),
336 ),
337 entity(EntityKind::Subscriber, "v1/**", "n", peers(&["total"])),
338 ],
339 };
340 let rows = join_consumers(TARGET, &declared, &[], None, "me");
341 let got: Vec<(&str, Relation)> =
342 rows.iter().map(|r| (r.zid.as_str(), r.relation)).collect();
343 assert_eq!(
344 got,
345 [
346 ("exact", Relation::Exact),
347 ("wider", Relation::Wider),
348 ("intersects", Relation::Intersects),
349 ("total", Relation::Total),
350 ]
351 );
352 assert_eq!(rows[1].kind, EntityKind::Querier);
353 }
354
355 #[test]
359 fn attribution_and_self_are_stated_not_guessed() {
360 let declared = DeclaredEntities {
361 entities: vec![
362 entity(EntityKind::Subscriber, TARGET, "reporter", json!({})),
363 entity(EntityKind::Subscriber, TARGET, "router1", peers(&["me"])),
364 entity(EntityKind::Subscriber, TARGET, "router2", peers(&["me"])),
365 ],
366 };
367 let topology = TopologyReport {
368 nodes: vec![TopologyNode {
369 zid: "me".into(),
370 whatami: "peer".into(),
371 version: None,
372 locators: vec![],
373 locators_via_links: vec![],
374 answered: true,
375 }],
376 edges: vec![],
377 asked: "@/*/*".into(),
378 answered: 1,
379 self_zid: "me".into(),
380 };
381 let rows = join_consumers(TARGET, &declared, &[], Some(&topology), "me");
382 assert_eq!(rows.len(), 2, "{rows:?}");
383 let me = rows.iter().find(|r| r.zid == "me").unwrap();
384 assert!(me.is_self);
385 assert_eq!(me.whatami.as_deref(), Some("peer"));
386 assert_eq!(me.attribution, Attribution::Session);
387 let reported = rows.iter().find(|r| r.zid == "reporter").unwrap();
388 assert_eq!(reported.attribution, Attribution::ReportedOnly);
389 assert!(reported.whatami.is_none(), "not heard of is not a kind");
390 }
391
392 #[test]
396 fn a_total_wildcard_does_not_reach_a_verbatim_plane() {
397 let declared = DeclaredEntities {
398 entities: vec![entity(EntityKind::Subscriber, "**", "n", peers(&["wide"]))],
399 };
400 let rows = join_consumers("v1/@catalog/state/entity/**", &declared, &[], None, "me");
401 assert!(rows.is_empty(), "{rows:?}");
402 assert_eq!(base_tree_of("acme/v1/@catalog/state/**"), "acme/v1/**");
403 assert_eq!(base_tree_of("@/*/*"), "**");
404 }
405
406 #[test]
407 fn declaring_sessions_count_distinct_zids() {
408 let declared = DeclaredEntities {
409 entities: vec![
410 entity(EntityKind::Publisher, TARGET, "n", peers(&["a", "b"])),
411 entity(EntityKind::Publisher, TARGET, "n", peers(&["a"])),
412 entity(EntityKind::Publisher, TARGET, "reporter", json!({})),
413 entity(EntityKind::Queryable, "v1/**", "n", peers(&["q"])),
414 entity(EntityKind::Queryable, "other/**", "n", peers(&["r"])),
415 ],
416 };
417 assert_eq!(
418 declaring_sessions(TARGET, &declared, EntityKind::Publisher),
419 3
420 );
421 assert_eq!(
422 declaring_sessions(TARGET, &declared, EntityKind::Queryable),
423 1
424 );
425 }
426
427 fn slices() -> crate::SliceSet {
428 let toml = r#"
429[registry]
430version = "1.0"
431app = "acme"
432convention = 1
433[producer]
434name = "sysinfo"
435
436[[subject]]
437path = "disk/{mount}/used"
438class = "telemetry"
439type = "TelemetryPoint"
440
441[[subject]]
442path = "health"
443class = "state"
444type = "HealthSnapshot"
445
446[[deprecated]]
447path = "ingest/legacy_total"
448since = "2.0"
449replaced_by = "disk/{mount}/bytes_used"
450"#;
451 let slice = zenkey::parse_slice(toml).expect("a slice");
452 crate::SliceSet::from_slices(vec![slice])
453 }
454
455 #[test]
456 fn a_subject_resolves_to_its_family_selector() {
457 let set = slices();
458 let t = subject_target(&set, "acme", "sysinfo", "disk/{mount}/used").unwrap();
459 assert_eq!(t.class, "telemetry");
460 assert_eq!(t.selector, "acme/v1/*/telemetry/sysinfo/disk/*/used");
461 assert!(t.deprecated.is_none());
462 let t = subject_target(&set, "", "sysinfo", "health").unwrap();
463 assert_eq!(t.selector, "v1/*/state/sysinfo/health");
464 let t = subject_target(&set, "", "sysinfo", "ingest/legacy_total").unwrap();
466 assert_eq!(t.class, "*");
467 assert_eq!(t.selector, "v1/*/*/sysinfo/ingest/legacy_total");
468 assert_eq!(
469 t.deprecated,
470 Some(DeprecationFact {
471 since: Some("2.0".into()),
472 replaced_by: Some("disk/{mount}/bytes_used".into()),
473 })
474 );
475 assert!(subject_target(&set, "", "sysinfo", "nope").is_none());
476 assert!(subject_target(&set, "", "logs", "health").is_none());
477 }
478}