zenkey_fleet/model/facts.rs
1//! What an explorer can honestly say about one observed key (RFC 09 §5.1).
2//!
3//! Born as zengui's `keyfacts` module and moved into the engine (issue #34)
4//! when RFC v1.9 made the classification ladder normative for *every* observer
5//! — shared policy belongs in the shared crate. The explorers' cores stay
6//! key-agnostic ([`crate::KeyTreeSnapshot`] groups on a plain `split('/')`);
7//! this is the enrichment layer on top: it projects a wire key onto the
8//! keyspace-v2 grammar when it can, and degrades to a stated reason when it
9//! cannot (O2). Nothing here ever rejects a key (O1).
10//!
11//! Two properties are load-bearing and are pinned by the tests below:
12//!
13//! - **Owned.** [`zenkey::grammar::StructuralKey`] borrows from the key string,
14//! so it cannot live in widget state. [`KeyFacts`] is the owned projection,
15//! computed *once* when a key is first observed — never per render.
16//! - **Base-relative, never by absolute index** (RFC 03 §1.1). Positions are
17//! resolved after [`strip_base`](zenkey::grammar::strip_base); a multi-chunk
18//! base (`acme/fleet-a`) and the empty base must give identical facts for the
19//! same subject.
20
21use crate::model::bounded::BoundedLru;
22use crate::model::registry::SliceSet;
23use zenkey::grammar::{self, BlobTier, Class, ClassOrPlane, Origin, Plane, StructuralKey};
24use zenkey::qos::QosProfile;
25use zenkey::{Declared, RateClass, SubjectKind, WireEncoding};
26
27/// Everything zengui knows about one wire key.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct KeyFacts {
30 pub shape: KeyShape,
31 pub registration: Registration,
32}
33
34/// How far the key got through the grammar.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum KeyShape {
37 /// Parses as `v1/<origin>/<class>/<producer>/<subject…>` under the active base.
38 V1(Box<V1Facts>),
39 /// The key does not sit under the active base. A *fact*, not a guess —
40 /// and unreachable when the base is empty, since `strip_base("", k)` is
41 /// the identity (RFC 03 §1.1).
42 ///
43 /// Deliberately does **not** try to name the key's own base: with no fixed
44 /// arity for a subject tail, guessing would mean a left-to-right "first
45 /// `v1`" scan, which RFC 09 §5 forbids for base attribution. Naming other
46 /// bases is the base picker's job (`discover_bases`), which attributes
47 /// fixed-arity from the right.
48 NotUnderBase,
49 /// Under the base, but not a v1 key — an ordinary plain Zenoh key. The
50 /// grammar's own message is kept verbatim; it already cites the RFC section.
51 Unparsed { reason: String },
52}
53
54/// Positions 3–6 of a conforming key, owned.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct V1Facts {
57 /// The origin chunk, verbatim.
58 pub origin: String,
59 pub origin_kind: OriginKind,
60 /// The class/plane chunk, verbatim.
61 pub class: String,
62 pub class_kind: ClassKind,
63 /// Producer base name. `None` under a service origin and under `@blob`,
64 /// where position 5 is a tier token instead (RFC 03 §1.5).
65 pub producer: Option<String>,
66 pub instance: Option<u32>,
67 /// Tier token, only under `@blob`.
68 pub blob_tier: Option<String>,
69 /// Everything after the producer/tier position.
70 pub subject: Vec<String>,
71}
72
73/// RFC 03 §1.3 licenses tooling to rely on the `h-[0-9a-f]{12}` shape to tell
74/// these apart — and RFC 03 §1.5 makes it the *sole* discriminator for whether
75/// position 5 is a producer or already subject.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum OriginKind {
78 Host,
79 Service,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum ClassKind {
84 Telemetry,
85 State,
86 Events,
87 Rpc,
88 Media,
89 Blob,
90}
91
92/// Whether the registry recognises this subject.
93///
94/// RFC 09 §5.1 O2 asks an observer to classify by degrading — "unregistered"
95/// and "no slice for this producer" are distinct rungs, each weakening the
96/// claim rather than discarding the key — and O4 is why a `bool` cannot be
97/// honest here: it renders "we have not loaded a registry yet" identically to
98/// "this subject is not registered". That is the false-verdict failure of
99/// RFC 05 §3.1 / RFC 12 §9 applied to a badge — *silence is never a verdict*,
100/// and neither is a not-yet-asked question.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum Registration {
103 /// No slice set loaded yet. We have not asked. Render as "—", never "wild".
104 Unknown,
105 /// Slices are loaded, but none declares this producer.
106 NoSliceForProducer,
107 /// The producer's slice is loaded and does not declare this subject.
108 /// "A subject that is not registered does not exist" (RFC 08) — for a
109 /// *conforming producer*. On the wire it is simply unregistered traffic.
110 Unregistered,
111 Registered(Box<SubjectFacts>),
112 /// The key has no registry surface to check: not under the base, unparsed,
113 /// or on a verbatim plane (the slice carries subjects, not plane keys).
114 NotApplicable,
115}
116
117/// The registry's description of a matched subject.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct SubjectFacts {
120 /// The declared pattern, e.g. `disk/{mount}/used`.
121 pub path: String,
122 pub type_name: String,
123 /// Variable bindings from the match, e.g. `[("mount", "var-log")]`.
124 pub vars: Vec<(String, String)>,
125 pub unit: Option<String>,
126 /// What the leaf value *is* (`counter | gauge | text | bool`, RFC 08 §2
127 /// v1.32), when declared — what the `kind-mismatch` judge compares the
128 /// wire against (RFC 13 §3). Absent is *not asked*.
129 pub kind: Option<Declared<SubjectKind>>,
130 pub qos: Option<Declared<QosProfile>>,
131 pub encoding: Option<WireEncoding>,
132 pub ttl_s: Option<i64>,
133 /// The declared events rate class (`rare` | `low` | `burst(n/h)`,
134 /// RFC 04 §1.3) — carried so observers can judge over-rate (#161).
135 pub rate: Option<RateClass>,
136 /// The declared key-population bound (RFC 08 §2) — carried so observers
137 /// can judge over-declared cardinality (#221).
138 pub cardinality: Option<i64>,
139 /// Registry version the subject first appeared in, when declared.
140 ///
141 /// Carried since the report-honesty batch (R2): the slice always had it,
142 /// and `TopicInfo.since` sat dead because this projection dropped it.
143 pub since: Option<String>,
144 /// The declared human description, same provenance (R2).
145 pub description: Option<String>,
146}
147
148impl SubjectFacts {
149 /// The declared QoS profile, where the registry names one this build
150 /// knows.
151 ///
152 /// `None` means the registry declares no profile for this subject — or
153 /// names one outside the vocabulary, which the RFC 08 §5 lints reject at
154 /// the producer's build; a live slice can still carry anything, and an
155 /// unparseable name must not be mistaken for a parsed one (#158). The
156 /// slice draws that line on parse now, so this reads it rather than
157 /// re-deriving it.
158 pub fn declared_qos(&self) -> Option<QosProfile> {
159 self.qos.as_ref().and_then(Declared::known).copied()
160 }
161}
162
163impl KeyFacts {
164 /// Project a full wire key against the active base. Infallible by design.
165 ///
166 /// Registration starts [`Registration::Unknown`] for a conforming data key;
167 /// call [`KeyFacts::resolve`] once a [`SliceSet`] is available. The two
168 /// steps are separate because they are invalidated by different things —
169 /// the base changes the shape, the slice set changes only the registration.
170 pub fn project(base: &str, wire_key: &str) -> KeyFacts {
171 let Some(relative) = grammar::strip_base(base, wire_key) else {
172 return KeyFacts {
173 shape: KeyShape::NotUnderBase,
174 registration: Registration::NotApplicable,
175 };
176 };
177 match grammar::parse(relative) {
178 Ok(parsed) => {
179 let facts = V1Facts::from_parsed(&parsed);
180 let registration = if facts.class_kind.is_data_class() {
181 Registration::Unknown
182 } else {
183 // A verbatim plane has no `[[subject]]` surface to match.
184 Registration::NotApplicable
185 };
186 KeyFacts {
187 shape: KeyShape::V1(Box::new(facts)),
188 registration,
189 }
190 }
191 Err(e) => KeyFacts {
192 shape: KeyShape::Unparsed {
193 reason: e.to_string(),
194 },
195 registration: Registration::NotApplicable,
196 },
197 }
198 }
199
200 /// Resolve the registration against a loaded slice set.
201 ///
202 /// Uses [`SliceSet::refine`], which applies RFC 08 §2's most-literal-first
203 /// precedence (literal beats `{var}` beats `{var...}`). Note `zenctl`'s
204 /// `offline::topic_info` predates `refine` and matches in *declaration*
205 /// order instead — do not copy it.
206 pub fn resolve(&mut self, slices: &SliceSet) {
207 let KeyShape::V1(facts) = &self.shape else {
208 return;
209 };
210 if !facts.class_kind.is_data_class() {
211 return;
212 }
213 // A service origin omits the producer chunk (RFC 03 §1.5), so its slice
214 // is found by the origin it serves, not by a producer name.
215 let producer = match facts.origin_kind {
216 OriginKind::Host => facts.producer.clone(),
217 OriginKind::Service => slices
218 .by_service_origin(&facts.origin)
219 .map(|s| s.name.clone()),
220 };
221 let Some(producer) = producer else {
222 self.registration = Registration::NoSliceForProducer;
223 return;
224 };
225 if slices.get(&producer).is_none() {
226 self.registration = Registration::NoSliceForProducer;
227 return;
228 }
229 let tail: Vec<&str> = facts.subject.iter().map(String::as_str).collect();
230 self.registration = match slices.refine(&producer, &facts.class, &tail) {
231 Some((decl, vars)) => Registration::Registered(Box::new(SubjectFacts {
232 path: decl.path.clone(),
233 type_name: decl.type_name.clone(),
234 vars,
235 unit: decl.unit.clone(),
236 kind: decl.kind.clone(),
237 qos: decl.qos.clone(),
238 encoding: decl.encoding.clone(),
239 ttl_s: decl.ttl_s,
240 rate: decl.rate.clone(),
241 cardinality: decl.cardinality,
242 since: decl.since.clone(),
243 description: decl.description.clone(),
244 })),
245 None => Registration::Unregistered,
246 };
247 }
248
249 /// The declared payload type, when the registry named one. Drives the echo
250 /// pane's type tag and, later, the schema lookup of RFC 08 §7.
251 pub fn type_name(&self) -> Option<&str> {
252 match &self.registration {
253 Registration::Registered(s) => Some(&s.type_name),
254 _ => None,
255 }
256 }
257}
258
259struct Entry {
260 facts: KeyFacts,
261 /// Monotone observation counter, not an `Instant`: recency here means
262 /// last-*observed*, the ordering is all that is read, and a counter is
263 /// deterministic in tests and one word per entry.
264 seen: u64,
265}
266
267impl std::fmt::Debug for Entry {
268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269 f.debug_struct("Entry").field("seen", &self.seen).finish()
270 }
271}
272
273/// A bounded cache of key projections, sized off the same `max_keys` as the
274/// [`StatsTable`](crate::model::stats::StatsTable) it shadows, counting what the bound
275/// costs (RFC 09 §5.1 O6).
276///
277/// **Why this exists** (issue #107). Projecting a key is not free — a
278/// [`KeyFacts`] owns a `String` per subject chunk plus the resolved
279/// [`SubjectFacts`] — so every observer caches it, and zengui's cache was a
280/// plain `HashMap` that grew one entry per distinct key *ever seen*. The engine's
281/// key table is bounded and counts its evictions; the projection cache shadowing
282/// it was, in `stats.rs`'s own words, "merely a leak with better manners".
283///
284/// **Why an LRU and not "prune to the stats table"**, which is the obvious fix:
285///
286/// - the table is fed from samples, and an observer also projects **liveliness
287/// token keys**, which never enter it. Pruning to the table would delete and
288/// re-project those on every tick, and drop them from any "keys seen" list;
289/// - a frontend holds a key-*tree* snapshot, not a key list, so membership means
290/// walking the tree per tick — O(n) allocation on the render thread at up to
291/// 50k keys, where this is O(1) amortised on the insert path;
292/// - "evicted because the table evicted it" and "evicted because it was never in
293/// the table" are different facts, and one counter over both is exactly what
294/// O6 forbids.
295///
296/// Recency is last-**observed**, not last-rendered, which is what keeps
297/// [`get`](Self::get) a pure read: a `&self` render path can look keys up
298/// without touching the ordering, so no interior mutability and no signature
299/// churn in the views.
300///
301/// The bound and the batch eviction are `BoundedLru`'s — shared with the
302/// [`StatsTable`](crate::model::stats::StatsTable) this shadows, which is where the
303/// argument for both was written. The **ledger** stays here: `inserted` /
304/// `evicted` are this cache's own facts, not the table's (O6).
305#[derive(Debug)]
306pub struct FactsCache {
307 entries: BoundedLru<String, Entry>,
308 inserted: u64,
309 evicted: u64,
310 seq: u64,
311}
312
313impl Default for FactsCache {
314 fn default() -> Self {
315 FactsCache::with_capacity(crate::model::bounded::DEFAULT_MAX_KEYS)
316 }
317}
318
319impl FactsCache {
320 /// A cache bounded at `max_keys` projections. Pass the same bound the
321 /// stats table was built with: the cache cannot usefully outgrow the table
322 /// it shadows, and one number makes that one sentence.
323 pub fn with_capacity(max_keys: usize) -> FactsCache {
324 FactsCache {
325 entries: BoundedLru::with_capacity(max_keys),
326 inserted: 0,
327 evicted: 0,
328 seq: 0,
329 }
330 }
331
332 /// Project `key` if it is not cached yet; bump its recency either way.
333 ///
334 /// The single insert point — the whole bound rests on that being true.
335 pub fn ensure(&mut self, base: &str, key: &str, slices: Option<&SliceSet>) {
336 self.seq += 1;
337 let seq = self.seq;
338 if let Some(entry) = self.entries.get_mut(key) {
339 entry.seen = seq;
340 return;
341 }
342 self.evicted += self.entries.admit(|e| e.seen) as u64;
343 let mut facts = KeyFacts::project(base, key);
344 if let Some(slices) = slices {
345 facts.resolve(slices);
346 }
347 self.entries
348 .insert(key.to_string(), Entry { facts, seen: seq });
349 self.inserted += 1;
350 }
351
352 /// A cached projection, if it is still held. Pure: recency is not touched,
353 /// so this is safe to call from a `&self` render path.
354 pub fn get(&self, key: &str) -> Option<&KeyFacts> {
355 self.entries.get(key).map(|e| &e.facts)
356 }
357
358 pub fn keys(&self) -> impl Iterator<Item = &str> {
359 self.entries.keys().map(String::as_str)
360 }
361
362 /// Every held projection, keyed. Unordered (the map is a `HashMap`) —
363 /// callers that need determinism collect into an ordered structure, which
364 /// is what both doctor and field context builders do.
365 pub fn iter(&self) -> impl Iterator<Item = (&str, &KeyFacts)> {
366 self.entries.iter().map(|(k, e)| (k.as_str(), &e.facts))
367 }
368
369 pub fn len(&self) -> usize {
370 self.entries.len()
371 }
372
373 pub fn is_empty(&self) -> bool {
374 self.entries.is_empty()
375 }
376
377 pub fn max_keys(&self) -> usize {
378 self.entries.max_keys()
379 }
380
381 /// Projections retired to stay within the bound.
382 ///
383 /// Displayed, never hidden: a cache that stopped growing and a bus that
384 /// went quiet look identical from the outside (RFC 09 §5.1 O6).
385 pub fn evicted(&self) -> u64 {
386 self.evicted
387 }
388
389 /// Projections *made* since the last [`clear`](Self::clear).
390 ///
391 /// The other half of the O6 ledger, and the reason it is a public number
392 /// rather than an internal one: `inserted == len() + evicted()` is the
393 /// conservation law, and without this counter it cannot be checked from
394 /// outside. Note it counts insertions, not distinct keys — a key evicted
395 /// and later re-observed is projected again, which is precisely the cost
396 /// the bound is trading against.
397 pub fn inserted(&self) -> u64 {
398 self.inserted
399 }
400
401 /// Re-resolve every held projection against a newly-loaded slice set —
402 /// what a registry arriving after the first samples calls for.
403 pub fn resolve_all(&mut self, slices: &SliceSet) {
404 for entry in self.entries.values_mut() {
405 entry.facts.resolve(slices);
406 }
407 }
408
409 /// Base change / reconnect / context switch. Keeps the bound and resets
410 /// the counter: retirements under another deployment are not this one's.
411 pub fn clear(&mut self) {
412 self.entries.clear();
413 self.inserted = 0;
414 self.evicted = 0;
415 self.seq = 0;
416 }
417}
418
419impl V1Facts {
420 fn from_parsed(parsed: &StructuralKey<'_>) -> V1Facts {
421 let (origin, origin_kind) = match &parsed.origin {
422 Origin::Host(id) => (id.as_str().to_string(), OriginKind::Host),
423 Origin::Service(s) => (s.as_str().to_string(), OriginKind::Service),
424 };
425 let (class, class_kind) = match parsed.class {
426 ClassOrPlane::Class(c) => (c.chunk().to_string(), ClassKind::from_class(c)),
427 ClassOrPlane::Plane(p) => (p.chunk().to_string(), ClassKind::from_plane(p)),
428 };
429 V1Facts {
430 origin,
431 origin_kind,
432 class,
433 class_kind,
434 producer: parsed.producer().map(|p| p.name().to_string()),
435 instance: parsed.producer().and_then(|p| p.instance()),
436 blob_tier: parsed.blob_tier().map(|t| tier_chunk(t).to_string()),
437 subject: parsed.subject.iter().map(|s| (*s).to_string()).collect(),
438 }
439 }
440}
441
442fn tier_chunk(tier: BlobTier) -> &'static str {
443 tier.chunk()
444}
445
446impl ClassKind {
447 fn from_class(c: Class) -> ClassKind {
448 match c {
449 Class::Telemetry => ClassKind::Telemetry,
450 Class::State => ClassKind::State,
451 Class::Events => ClassKind::Events,
452 }
453 }
454
455 fn from_plane(p: Plane) -> ClassKind {
456 match p {
457 Plane::Rpc => ClassKind::Rpc,
458 Plane::Media => ClassKind::Media,
459 Plane::Blob => ClassKind::Blob,
460 }
461 }
462
463 /// The three data classes carry `[[subject]]` entries; the verbatim planes
464 /// do not (RFC 03 §1.4).
465 pub fn is_data_class(self) -> bool {
466 matches!(
467 self,
468 ClassKind::Telemetry | ClassKind::State | ClassKind::Events
469 )
470 }
471}
472
473/// A key, fully described as far as the ladder reaches — the engine-side
474/// replacement for zenctl's old `offline::topic_info`, which hard-errored on
475/// non-v1 keys (an O1 violation) and matched subjects in declaration order
476/// (diverging from [`SliceSet::refine`]'s most-literal-first precedence).
477///
478/// Infallible by design: every key gets a description; the description says
479/// how far it got.
480#[derive(Debug, Clone, PartialEq, Eq)]
481pub struct KeyDescription {
482 /// The key as given (full wire form).
483 pub key: String,
484 pub facts: KeyFacts,
485}
486
487/// Project and resolve in one call.
488///
489/// `slices` is an `Option` on purpose: `None` means *no registry was loaded*,
490/// which must stay distinguishable from `Some(empty)` — a registry that was
491/// loaded and covers nothing. "Not asked" is not "answered no" (O4).
492pub fn describe_key(base: &str, key: &str, slices: Option<&SliceSet>) -> KeyDescription {
493 let mut facts = KeyFacts::project(base, key);
494 if let Some(slices) = slices {
495 facts.resolve(slices);
496 }
497 KeyDescription {
498 key: key.to_string(),
499 facts,
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 fn v1(facts: &KeyFacts) -> &V1Facts {
508 match &facts.shape {
509 KeyShape::V1(f) => f,
510 other => panic!("expected a v1 key, got {other:?}"),
511 }
512 }
513
514 #[test]
515 fn projects_a_host_telemetry_key() {
516 let f = KeyFacts::project(
517 "zensight",
518 "zensight/v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu/usage",
519 );
520 let v = v1(&f);
521 assert_eq!(v.origin, "h-3fa9c2d41b7e");
522 assert_eq!(v.origin_kind, OriginKind::Host);
523 assert_eq!(v.class, "telemetry");
524 assert_eq!(v.producer.as_deref(), Some("sysinfo"));
525 assert_eq!(v.instance, None);
526 assert_eq!(v.subject, ["cpu", "usage"]);
527 // No slice set has been consulted yet — that is not "unregistered".
528 assert_eq!(f.registration, Registration::Unknown);
529 }
530
531 /// RFC 03 §1.1: positions are resolved *relative to the configured base*,
532 /// never by absolute index. The empty base, a one-chunk base and a
533 /// multi-chunk base must all yield identical facts for the same subject.
534 #[test]
535 fn positions_are_base_relative_never_absolute() {
536 let subject = "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu/usage";
537 let cases = [
538 ("", subject.to_string()),
539 ("zensight", format!("zensight/{subject}")),
540 ("acme/fleet-a", format!("acme/fleet-a/{subject}")),
541 ];
542 let projected: Vec<V1Facts> = cases
543 .iter()
544 .map(|(base, key)| v1(&KeyFacts::project(base, key)).clone())
545 .collect();
546 assert_eq!(projected[0], projected[1]);
547 assert_eq!(projected[1], projected[2]);
548 assert_eq!(projected[0].producer.as_deref(), Some("sysinfo"));
549 }
550
551 /// RFC 03 §1.5: chunk 5 is producer-or-subject, disambiguated by the origin
552 /// chunk *alone*. A service origin omits the producer position entirely.
553 #[test]
554 fn origin_chunk_alone_decides_whether_chunk_five_is_a_producer() {
555 let host = KeyFacts::project("", "v1/h-3fa9c2d41b7e/state/sysinfo/health");
556 assert_eq!(v1(&host).producer.as_deref(), Some("sysinfo"));
557 assert_eq!(v1(&host).subject, ["health"]);
558
559 let service = KeyFacts::project("", "v1/@catalog/state/entity/x");
560 assert_eq!(v1(&service).origin_kind, OriginKind::Service);
561 assert_eq!(v1(&service).origin, "@catalog");
562 assert_eq!(v1(&service).producer, None);
563 // `entity` is already subject here, not a producer.
564 assert_eq!(v1(&service).subject, ["entity", "x"]);
565 }
566
567 #[test]
568 fn parses_a_producer_instance_suffix() {
569 let f = KeyFacts::project("", "v1/h-3fa9c2d41b7e/telemetry/snmp-2/if/eth0/in");
570 assert_eq!(v1(&f).producer.as_deref(), Some("snmp"));
571 assert_eq!(v1(&f).instance, Some(2));
572 }
573
574 /// Under `@blob` position 5 is a tier token, not a producer (RFC 03 §1.5).
575 #[test]
576 fn blob_tier_occupies_the_producer_position() {
577 let f = KeyFacts::project("", "v1/h-3fa9c2d41b7e/@blob/store/sha256/abcdef01");
578 let v = v1(&f);
579 assert_eq!(v.class_kind, ClassKind::Blob);
580 assert_eq!(v.producer, None);
581 assert_eq!(v.blob_tier.as_deref(), Some("store"));
582 // A verbatim plane has no `[[subject]]` surface — not "unregistered".
583 assert_eq!(f.registration, Registration::NotApplicable);
584 }
585
586 #[test]
587 fn a_key_under_another_base_is_a_fact_not_an_error() {
588 let f = KeyFacts::project("zensight", "other/v1/h-3fa9c2d41b7e/state/sysinfo/health");
589 assert_eq!(f.shape, KeyShape::NotUnderBase);
590 // We deliberately do not name `other` — that would need the
591 // "first v1" scan RFC 09 §5 forbids.
592 }
593
594 /// `strip_base("", k)` is the identity, so with the (default) empty base
595 /// every key is under the base and `NotUnderBase` is unreachable.
596 #[test]
597 fn empty_base_makes_not_under_base_unreachable() {
598 for key in [
599 "v1/h-3fa9c2d41b7e/state/sysinfo/health",
600 "zensight/v1/h-3fa9c2d41b7e/state/sysinfo/health",
601 "demo/example/foo",
602 "",
603 ] {
604 assert_ne!(
605 KeyFacts::project("", key).shape,
606 KeyShape::NotUnderBase,
607 "{key}"
608 );
609 }
610 }
611
612 /// The whole point of the key-agnostic core: a plain Zenoh key is not an
613 /// error, it is a key we can still count, group and render.
614 #[test]
615 fn arbitrary_keys_degrade_to_a_stated_reason() {
616 for key in ["demo/example/foo", "v2/h-3fa9c2d41b7e/state/x/y", "a", ""] {
617 let f = KeyFacts::project("", key);
618 match f.shape {
619 KeyShape::Unparsed { reason } => assert!(!reason.is_empty(), "{key}"),
620 other => panic!("{key} should be unparsed, got {other:?}"),
621 }
622 assert_eq!(f.registration, Registration::NotApplicable);
623 }
624 }
625
626 /// An `@`-chunk in an otherwise foreign key must not panic or be mistaken
627 /// for a plane — this is the shape a hostile/foreign publisher produces.
628 #[test]
629 fn foreign_keys_with_verbatim_chunks_are_merely_unparsed() {
630 let f = KeyFacts::project("", "demo/@thing/foo");
631 assert!(matches!(f.shape, KeyShape::Unparsed { .. }));
632 }
633
634 #[test]
635 fn unknown_registration_is_not_unregistered() {
636 // The distinction the tri-state exists for.
637 assert_ne!(Registration::Unknown, Registration::Unregistered);
638 }
639
640 /// `describe_key` must use refine's most-literal-first precedence: a
641 /// literal leaf beats a `{var}` even when the var is declared first.
642 /// (The old zenctl `topic_info` matched in declaration order — the exact
643 /// divergence issue #34 exists to kill.)
644 #[test]
645 fn describe_key_prefers_the_literal_over_the_variable() {
646 use zenkey::slice::{RegistrySlice, SubjectDecl};
647 let subject = |path: &str| {
648 let mut d = SubjectDecl::new(path, Class::Telemetry);
649 d.type_name = if path.contains('{') {
650 "VarPoint"
651 } else {
652 "SpecialPoint"
653 }
654 .to_string();
655 d
656 };
657 let mut slice = RegistrySlice::new("1.0", "test", "flowd");
658 // The {var} pattern is declared FIRST — declaration order must not win.
659 slice.subjects = vec![subject("flow/{q}"), subject("flow/special")];
660 let slices = SliceSet::from_slices(vec![slice]);
661 let d = describe_key(
662 "",
663 "v1/h-3fa9c2d41b7e/telemetry/flowd/flow/special",
664 Some(&slices),
665 );
666 match &d.facts.registration {
667 Registration::Registered(s) => {
668 assert_eq!(s.path, "flow/special", "literal must beat {{var}}");
669 assert_eq!(s.type_name, "SpecialPoint");
670 }
671 other => panic!("expected Registered, got {other:?}"),
672 }
673 // …and the variable pattern still catches everything else.
674 let d = describe_key(
675 "",
676 "v1/h-3fa9c2d41b7e/telemetry/flowd/flow/p95",
677 Some(&slices),
678 );
679 match &d.facts.registration {
680 Registration::Registered(s) => assert_eq!(s.path, "flow/{q}"),
681 other => panic!("expected Registered, got {other:?}"),
682 }
683 }
684
685 /// #158: the declared profile parses into the closed vocabulary, and an
686 /// out-of-vocabulary name degrades to `None` instead of a wrong profile.
687 #[test]
688 fn declared_qos_parses_the_closed_vocabulary_only() {
689 let facts = |qos: Option<&str>| SubjectFacts {
690 path: "cpu/usage".into(),
691 type_name: "Point".into(),
692 vars: vec![],
693 unit: None,
694 kind: None,
695 qos: qos.map(Declared::parse),
696 encoding: None,
697 ttl_s: None,
698 rate: None,
699 cardinality: None,
700 since: None,
701 description: None,
702 };
703 assert_eq!(
704 facts(Some("transition")).declared_qos(),
705 Some(zenkey::qos::QosProfile::Transition)
706 );
707 assert_eq!(facts(None).declared_qos(), None);
708 assert_eq!(facts(Some("best-effort-ish")).declared_qos(), None);
709 }
710
711 /// O1: a key that does not parse still gets a full description.
712 #[test]
713 fn describe_key_never_fails() {
714 for key in ["demo/example/foo", "", "v2/x", "@weird/key"] {
715 let d = describe_key("", key, None);
716 assert_eq!(d.key, key);
717 assert!(matches!(d.facts.shape, KeyShape::Unparsed { .. }), "{key}");
718 }
719 let d = describe_key("zensight", "other/v1/h-3fa9c2d41b7e/state/x/y", None);
720 assert_eq!(d.facts.shape, KeyShape::NotUnderBase);
721 }
722}
723
724// ── FactsCache (#107) ───────────────────────────────────────────────────
725
726#[cfg(test)]
727mod cache_tests {
728 use super::*;
729
730 fn key(i: usize) -> String {
731 format!("v1/h-3fa9c2d41b7e/telemetry/sysinfo/k{i}")
732 }
733
734 #[test]
735 fn the_bound_holds_and_every_drop_is_counted() {
736 let mut cache = FactsCache::with_capacity(100);
737 for i in 0..1_000 {
738 cache.ensure("", &key(i), None);
739 }
740 assert!(cache.len() <= 100, "held {}", cache.len());
741 assert!(cache.evicted() > 0, "the fixture must trip the bound");
742 // The ledger #107 asks for: nothing vanishes unaccounted.
743 assert_eq!(cache.inserted(), 1_000, "every key here was distinct");
744 assert_eq!(cache.len() as u64 + cache.evicted(), cache.inserted());
745 }
746
747 /// A key evicted and later re-observed is projected *again* — the cost the
748 /// bound trades against, and the reason the ledger counts insertions rather
749 /// than distinct keys.
750 #[test]
751 fn a_re_observed_eviction_is_projected_again() {
752 let mut cache = FactsCache::with_capacity(2);
753 for i in 0..10 {
754 cache.ensure("", &key(i), None);
755 }
756 let after_first_pass = cache.inserted();
757 for i in 0..10 {
758 cache.ensure("", &key(i), None);
759 }
760 assert!(
761 cache.inserted() > after_first_pass,
762 "a second pass over evicted keys re-projects them"
763 );
764 assert_eq!(cache.len() as u64 + cache.evicted(), cache.inserted());
765 }
766
767 #[test]
768 fn the_least_recently_observed_is_the_one_that_goes() {
769 let mut cache = FactsCache::with_capacity(4);
770 for i in 0..4 {
771 cache.ensure("", &key(i), None);
772 }
773 // Re-observing k0 makes k1 the oldest, so the next eviction takes k1
774 // and spares k0 — recency is last-*observed*, and this is what says so.
775 cache.ensure("", &key(0), None);
776 cache.ensure("", &key(99), None);
777 assert!(cache.get(&key(0)).is_some(), "the re-observed key survives");
778 assert!(cache.get(&key(1)).is_none(), "the oldest went instead");
779 }
780
781 #[test]
782 fn ensure_is_idempotent_and_does_not_reproject() {
783 let slices = SliceSet::default();
784 let mut cache = FactsCache::with_capacity(10);
785 cache.ensure("", &key(0), None);
786 let before = cache.get(&key(0)).cloned();
787 cache.ensure("", &key(0), Some(&slices));
788 assert_eq!(
789 cache.get(&key(0)).cloned(),
790 before,
791 "a second ensure must not re-resolve behind the caller's back"
792 );
793 assert_eq!(cache.len(), 1);
794 }
795
796 #[test]
797 fn resolve_all_reaches_entries_projected_before_the_registry_arrived() {
798 // The ordinary startup order: samples first, slices second.
799 let mut cache = FactsCache::with_capacity(10);
800 cache.ensure("", &key(0), None);
801 assert_eq!(
802 cache.get(&key(0)).map(|f| f.registration.clone()),
803 Some(Registration::Unknown)
804 );
805 cache.resolve_all(&SliceSet::default());
806 assert_ne!(
807 cache.get(&key(0)).map(|f| f.registration.clone()),
808 Some(Registration::Unknown),
809 "a registry that arrives late still reaches what was already cached"
810 );
811 }
812
813 #[test]
814 fn clearing_keeps_the_bound_and_forgets_the_count() {
815 let mut cache = FactsCache::with_capacity(4);
816 for i in 0..40 {
817 cache.ensure("", &key(i), None);
818 }
819 assert!(cache.evicted() > 0);
820 cache.clear();
821 assert!(cache.is_empty());
822 assert_eq!(cache.max_keys(), 4, "the bound is a setting, not a state");
823 assert_eq!(
824 cache.evicted(),
825 0,
826 "retirements under another deployment are not this one's"
827 );
828 assert_eq!(cache.inserted(), 0);
829 }
830
831 #[test]
832 fn a_degenerate_bound_is_still_a_bound() {
833 let mut cache = FactsCache::with_capacity(0);
834 for i in 0..10 {
835 cache.ensure("", &key(i), None);
836 }
837 assert_eq!(cache.max_keys(), 1);
838 assert!(cache.len() <= 1);
839 }
840}