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, 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}