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