Skip to main content

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