Skip to main content

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}