Skip to main content

zenkey_fleet/model/
registry.rs

1//! Registry-slice sets (RFC 08 §6): one type over both sources.
2//!
3//! A slice is a slice regardless of where it was read — a producer's served
4//! `introspect` reply off the live bus, or a local `registry/*.toml` file.
5//! [`SliceSet`] carries them uniformly (with an optional on-disk cache so
6//! repeated invocations and shell completion answer instantly), and exposes
7//! the subject-refinement lookups every renderer needs.
8
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12use crate::report::SliceDisagreement;
13use crate::report::{ProducerDiff, RegistryDiff};
14use crate::{Error, Result};
15use zenkey::{Declared, RegistrySlice, parse_slice};
16
17/// One slice's subject patterns, parsed once and grouped by class.
18///
19/// `refine` runs **per sample** on zenctl's decode path and per first-sight
20/// key in zengui, and it used to parse every subject pattern of the class on
21/// every call — then clone them all again to hand `best_match` a contiguous
22/// slice. Parsing at construction turns that into a map lookup
23/// (`docs/zero-copy.md`).
24#[derive(Debug, Clone, Default)]
25struct ParsedSubjects {
26    /// Index into the slice's own `subjects`, parallel to `pats`.
27    idx: Vec<usize>,
28    /// Contiguous, so `best_match` takes it borrowed.
29    pats: Vec<zenkey::pattern::SubjectPattern>,
30}
31
32/// A set of registry slices, indexed by producer/service base name.
33#[derive(Debug, Clone, Default)]
34pub struct SliceSet {
35    slices: Vec<RegistrySlice>,
36    /// The raw TOML per slice, kept for the disk cache (slices do not
37    /// re-serialize; the served text is the artifact).
38    raw: Vec<String>,
39    /// Parsed subject patterns per slice, keyed by class. Rebuilt wholesale
40    /// with its slice — the two vectors are index-parallel, and `push` is the
41    /// only place either grows.
42    parsed: Vec<std::collections::BTreeMap<String, ParsedSubjects>>,
43    /// Producer base name → index into the three parallel vectors.
44    ///
45    /// [`get`](Self::get) and [`refine`](Self::refine) run **per sample** on
46    /// the decode path, and both used to scan `slices` by name — a linear
47    /// walk over a fleet's whole producer set, per key, to answer a question
48    /// a map answers.
49    ///
50    /// **First wins**, because that is `find`/`position`'s rule and the
51    /// shadowing it implies is observable: [`from_slices`](Self::from_slices)
52    /// does not go through `push` and can be handed the same name twice, and
53    /// the one that answers is the earlier. `push` replaces in place, so a
54    /// re-pushed producer keeps its index — and its position in
55    /// [`slices`](Self::slices) and [`entries`](Self::entries).
56    by_name: std::collections::BTreeMap<String, usize>,
57}
58
59/// Group one slice's subjects by class, parsing each pattern once. A subject
60/// whose pattern does not parse is dropped here exactly as it was dropped
61/// per-call before — a malformed declaration refines nothing.
62fn parse_subjects(slice: &RegistrySlice) -> std::collections::BTreeMap<String, ParsedSubjects> {
63    let mut out: std::collections::BTreeMap<String, ParsedSubjects> = Default::default();
64    for (i, s) in slice.subjects.iter().enumerate() {
65        if let Ok(p) = zenkey::pattern::SubjectPattern::parse(&s.path) {
66            let entry = out.entry(s.class.token().to_string()).or_default();
67            entry.idx.push(i);
68            entry.pats.push(p);
69        }
70    }
71    out
72}
73
74impl SliceSet {
75    /// Load from local `registry/*.toml` dirs — the offline source. What a
76    /// checked-out application *declares*. (`types.toml` is the type table,
77    /// not a slice — skipped.)
78    pub fn from_dirs(dirs: &[PathBuf]) -> Result<SliceSet> {
79        let mut set = SliceSet::default();
80        for dir in dirs {
81            let mut paths: Vec<_> = std::fs::read_dir(dir)
82                .map_err(|e| Error::io(dir, e))?
83                .filter_map(|e| e.ok().map(|e| e.path()))
84                .filter(|p| p.extension().is_some_and(|e| e == "toml"))
85                .filter(|p| p.file_name().is_none_or(|n| n != "types.toml"))
86                .collect();
87            paths.sort();
88            for path in paths {
89                let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
90                let slice = parse_slice(&text)
91                    .map_err(|e| Error::malformed_from(path.display().to_string(), e))?;
92                set.push(slice, text);
93            }
94        }
95        Ok(set)
96    }
97
98    /// Discover every live producer's served slice from the bus
99    /// ([`crate::bus::query::fleet_registry`]).
100    pub async fn from_bus(fleet: &crate::Fleet<'_>, timeout: Duration) -> Result<SliceSet> {
101        let pairs = crate::bus::query::fleet_registry_raw(fleet, timeout).await?;
102        let mut set = SliceSet::default();
103        for (slice, raw) in pairs {
104            set.push(slice, raw);
105        }
106        Ok(set)
107    }
108
109    fn push(&mut self, slice: RegistrySlice, raw: String) {
110        // One slice per base name; last one wins (a fleet mid-rollout serves
111        // several versions — the newest reply is as good a pick as any, and
112        // `doctor` is where disagreement is *reported*).
113        let parsed = parse_subjects(&slice);
114        if let Some(&i) = self.by_name.get(&slice.name) {
115            self.slices[i] = slice;
116            self.raw[i] = raw;
117            self.parsed[i] = parsed;
118        } else {
119            self.by_name.insert(slice.name.clone(), self.slices.len());
120            self.slices.push(slice);
121            self.raw.push(raw);
122            self.parsed.push(parsed);
123        }
124    }
125
126    /// Each slice with the raw TOML it was parsed from — the pair
127    /// `write_cache` persists. The text is empty for a set built by
128    /// [`from_slices`](Self::from_slices), which has none to give.
129    pub fn entries(&self) -> impl Iterator<Item = (&RegistrySlice, &str)> {
130        self.slices.iter().zip(self.raw.iter().map(String::as_str))
131    }
132
133    pub fn slices(&self) -> &[RegistrySlice] {
134        &self.slices
135    }
136
137    pub fn get(&self, name: &str) -> Option<&RegistrySlice> {
138        self.by_name.get(name).map(|&i| &self.slices[i])
139    }
140
141    /// The slice declaring a service origin (`@catalog`) — service keys have
142    /// no producer chunk, so refinement resolves through this.
143    pub fn by_service_origin(&self, origin: &str) -> Option<&RegistrySlice> {
144        self.slices
145            .iter()
146            .find(|s| s.service_origin.as_ref().map(Declared::token) == Some(origin))
147    }
148
149    /// Refine a subject tail against one producer's slice: the matching
150    /// subject declaration plus its named variable bindings.
151    pub fn refine<'s>(
152        &'s self,
153        producer: &str,
154        class: &str,
155        tail: &[&str],
156    ) -> Option<(&'s zenkey::slice::SubjectDecl, Vec<(String, String)>)> {
157        let i = *self.by_name.get(producer)?;
158        let slice = &self.slices[i];
159        // Precedence-ordered via the shared matcher (issue #7): the class's
160        // patterns were parsed at construction, so this is a map lookup and a
161        // borrowed slice — no parse, no clone, per sample.
162        let candidates = self.parsed[i].get(class)?;
163        let (winner, binds) = zenkey::pattern::best_match(&candidates.pats, tail)?;
164        let subject_idx = candidates.idx[winner];
165        Some((
166            &slice.subjects[subject_idx],
167            binds.into_iter().map(|(n, v)| (n.to_string(), v)).collect(),
168        ))
169    }
170
171    /// Build from already-parsed slices (no raw TOML retained — such a set
172    /// is skipped by `write_cache`).
173    pub fn from_slices(slices: Vec<RegistrySlice>) -> SliceSet {
174        let raw = vec![String::new(); slices.len()];
175        let parsed = slices.iter().map(parse_subjects).collect();
176        // `or_insert`, not `insert`: first wins, which is what the linear
177        // `find` this replaced did with a duplicated name.
178        let mut by_name = std::collections::BTreeMap::new();
179        for (i, s) in slices.iter().enumerate() {
180            by_name.entry(s.name.clone()).or_insert(i);
181        }
182        SliceSet {
183            slices,
184            raw,
185            parsed,
186            by_name,
187        }
188    }
189
190    /// Write the raw slice TOMLs to a cache dir (one file per producer).
191    /// Repeated invocations and dynamic shell completion read this instead
192    /// of round-tripping the bus.
193    pub fn write_cache(&self, dir: &Path) -> Result<()> {
194        std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
195        for (slice, raw) in self.slices.iter().zip(&self.raw) {
196            if raw.is_empty() {
197                continue; // from_slices sets: nothing faithful to persist
198            }
199            let path = dir.join(format!("{}.toml", slice.name));
200            std::fs::write(&path, raw).map_err(|e| Error::io(&path, e))?;
201        }
202        Ok(())
203    }
204
205    /// Read a previously written cache dir. Same forgiving posture as
206    /// `from_dirs`, but a missing dir is an empty set, not an error.
207    pub fn read_cache(dir: &Path) -> SliceSet {
208        if !dir.is_dir() {
209            return SliceSet::default();
210        }
211        SliceSet::from_dirs(&[dir.to_path_buf()]).unwrap_or_default()
212    }
213}
214
215/// Where a slice set came from — the §6.1 decision made typed: `--registry`
216/// and the bus stop being exclusive.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum SliceSource {
219    Bus,
220    Dirs,
221    Union,
222}
223
224/// A union load's full outcome.
225#[derive(Debug, Clone)]
226pub struct UnionOutcome {
227    pub set: SliceSet,
228    /// Producers whose slice came from the bus.
229    pub from_bus: Vec<String>,
230    /// Producers only the dirs supplied.
231    pub dirs_only: Vec<String>,
232    pub disagreements: Vec<SliceDisagreement>,
233}
234
235impl SliceSet {
236    /// Load the union of the live bus and local dirs: **served wins per
237    /// producer**, dirs fill the gaps, and every producer where the two
238    /// disagree is retained as a [`SliceDisagreement`].
239    ///
240    /// Degrades honestly: an unreachable bus yields a dirs-only union (the
241    /// outcome's `from_bus` is empty — the caller can see which case it got).
242    pub async fn from_union(
243        fleet: &crate::Fleet<'_>,
244        dirs: &[std::path::PathBuf],
245        timeout: std::time::Duration,
246    ) -> Result<UnionOutcome> {
247        let bus = SliceSet::from_bus(fleet, timeout).await.unwrap_or_default();
248        let disk = if dirs.is_empty() {
249            SliceSet::default()
250        } else {
251            SliceSet::from_dirs(dirs)?
252        };
253
254        // Carry each slice's raw TOML through the merge (issue #54): a union
255        // that dropped it produced a set `write_cache` silently skipped, so
256        // the `--registry` path — the offline one, where a warm completion
257        // cache matters most — cached nothing at all.
258        let mut merged = SliceSet::default();
259        let mut from_bus = Vec::new();
260        let mut dirs_only = Vec::new();
261        let mut disagreements = Vec::new();
262
263        for (served, raw) in bus.entries() {
264            from_bus.push(served.name.clone());
265            if let Some(local) = disk.get(&served.name)
266                && (local.version != served.version || local != served)
267            {
268                disagreements.push(SliceDisagreement {
269                    producer: served.name.clone(),
270                    bus_version: served.version.clone(),
271                    dirs_version: local.version.clone(),
272                    shape_differs: {
273                        // Same version but different content is the worse lie.
274                        let mut a = served.clone();
275                        let mut b = local.clone();
276                        a.version = String::new();
277                        b.version = String::new();
278                        a != b
279                    },
280                });
281            }
282            merged.push(served.clone(), raw.to_string());
283        }
284        for (local, raw) in disk.entries() {
285            if bus.get(&local.name).is_none() {
286                dirs_only.push(local.name.clone());
287                merged.push(local.clone(), raw.to_string());
288            }
289        }
290
291        Ok(UnionOutcome {
292            set: merged,
293            from_bus,
294            dirs_only,
295            disagreements,
296        })
297    }
298}
299
300impl SliceSet {
301    /// Compare this set — what the fleet **serves** — against what a checkout
302    /// **declares**, per producer.
303    ///
304    /// Pure, so the comparison is testable without a bus, and engine-side so
305    /// both explorers can make it (issue #208). The per-producer comparison
306    /// is already `zenkey::slice::diff`; this is the set-level join that
307    /// decides what to do about a producer only one side knows.
308    pub fn diff(&self, local: &SliceSet) -> RegistryDiff {
309        let served = self;
310        let mut names: Vec<&str> = served
311            .slices()
312            .iter()
313            .chain(local.slices())
314            .map(|s| s.name.as_str())
315            .collect();
316        names.sort_unstable();
317        names.dedup();
318
319        let mut producers = Vec::new();
320        for name in names {
321            let s = served.get(name);
322            let l = local.get(name);
323            producers.push(match (s, l) {
324                (Some(s), Some(l)) => ProducerDiff {
325                    producer: name.to_string(),
326                    served_version: Some(s.version.clone()),
327                    local_version: Some(l.version.clone()),
328                    findings: zenkey::slice::diff(s, l)
329                        .iter()
330                        .map(|f| f.summary())
331                        .collect(),
332                },
333                // Present on one side only. Neither is an error: a producer the
334                // bus serves and the checkout does not know may simply be newer,
335                // and one the checkout declares that nothing serves may simply be
336                // down (RFC 05 §3.1 — silence is not a verdict).
337                (Some(s), None) => ProducerDiff {
338                    producer: name.to_string(),
339                    served_version: Some(s.version.clone()),
340                    local_version: None,
341                    findings: vec!["served by the fleet, absent from the local registry".into()],
342                },
343                (None, Some(l)) => ProducerDiff {
344                    producer: name.to_string(),
345                    served_version: None,
346                    local_version: Some(l.version.clone()),
347                    findings: vec![
348                        "declared locally, not served by any origin — down, or not deployed \
349                         (silence is not a verdict, RFC 05 §3.1)"
350                            .into(),
351                    ],
352                },
353                (None, None) => unreachable!("name came from one of the two sets"),
354            });
355        }
356        RegistryDiff { producers }
357    }
358}
359
360#[cfg(test)]
361impl SliceSet {
362    /// Test constructor from one slice TOML (crate-internal).
363    pub(crate) fn from_toml_for_tests(toml: &str) -> SliceSet {
364        let mut set = SliceSet::default();
365        set.push(parse_slice(toml).unwrap(), toml.to_string());
366        set
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    const A: &str = r#"
375        [registry]
376        version = "1.0"
377        app = "t"
378        convention = 1
379        [producer]
380        name = "alpha"
381        [[subject]]
382        path = "flow/{q}"
383        class = "telemetry"
384        type = "Point"
385        [[subject]]
386        path = "flow/special"
387        class = "telemetry"
388        type = "Special"
389    "#;
390
391    #[test]
392    fn refine_uses_shared_precedence() {
393        let mut set = SliceSet::default();
394        set.push(parse_slice(A).unwrap(), A.to_string());
395        // Literal beats {var} — the shared best_match ordering.
396        let (s, binds) = set
397            .refine("alpha", "telemetry", &["flow", "special"])
398            .unwrap();
399        assert_eq!(s.type_name, "Special");
400        assert!(binds.is_empty());
401        let (s, binds) = set.refine("alpha", "telemetry", &["flow", "p95"]).unwrap();
402        assert_eq!(s.type_name, "Point");
403        assert_eq!(binds, vec![("q".to_string(), "p95".to_string())]);
404        assert!(set.refine("alpha", "state", &["flow", "p95"]).is_none());
405    }
406
407    #[test]
408    fn cache_round_trips_and_last_slice_wins() {
409        let mut set = SliceSet::default();
410        set.push(parse_slice(A).unwrap(), A.to_string());
411        // A newer slice for the same producer replaces, never duplicates.
412        set.push(parse_slice(A).unwrap(), A.to_string());
413        assert_eq!(set.slices().len(), 1);
414
415        let dir = std::env::temp_dir().join(format!("zenkey-fleet-cache-{}", std::process::id()));
416        let _ = std::fs::remove_dir_all(&dir);
417        set.write_cache(&dir).unwrap();
418        let back = SliceSet::read_cache(&dir);
419        assert_eq!(back.slices().len(), 1);
420        assert_eq!(back.get("alpha").unwrap().subjects.len(), 2);
421        let _ = std::fs::remove_dir_all(&dir);
422        // Missing dir: empty set, not an error.
423        assert!(
424            SliceSet::read_cache(Path::new("/nonexistent-zkf"))
425                .slices()
426                .is_empty()
427        );
428    }
429
430    /// The name index answers exactly what the linear scan answered.
431    ///
432    /// Two rules, both observable, both easy to lose to a map: a re-pushed
433    /// producer replaces **in place** (so `slices()` order is stable and the
434    /// newest slice is the one that refines), and a set built through
435    /// [`SliceSet::from_slices`] — which does not go through `push` — can
436    /// hold the same name twice, where the **first** answers.
437    #[test]
438    fn a_re_pushed_producer_keeps_its_place_and_shadowing_is_first_wins() {
439        let newer = A.replace("version = \"1.0\"", "version = \"9.9\"");
440        let other = A.replace("name = \"alpha\"", "name = \"beta\"");
441
442        let mut set = SliceSet::default();
443        set.push(parse_slice(A).unwrap(), A.to_string());
444        set.push(parse_slice(&other).unwrap(), other.clone());
445        set.push(parse_slice(&newer).unwrap(), newer.clone());
446
447        assert_eq!(set.slices().len(), 2, "a re-push replaces, never appends");
448        assert_eq!(
449            set.slices()[0].name,
450            "alpha",
451            "the replacement keeps the producer's position"
452        );
453        assert_eq!(set.get("alpha").unwrap().version, "9.9", "last push wins");
454        assert_eq!(
455            set.entries().next().unwrap().1,
456            newer,
457            "the raw TOML rides with the slice it was parsed from"
458        );
459        assert!(set.get("gamma").is_none());
460        // …and refinement still resolves through the replaced slice.
461        assert_eq!(
462            set.refine("alpha", "telemetry", &["flow", "special"])
463                .unwrap()
464                .0
465                .type_name,
466            "Special"
467        );
468        assert!(set.refine("gamma", "telemetry", &["flow"]).is_none());
469
470        // The shadowing `from_slices` can produce: first wins, both ways.
471        let shadowed =
472            SliceSet::from_slices(vec![parse_slice(A).unwrap(), parse_slice(&newer).unwrap()]);
473        assert_eq!(
474            shadowed.get("alpha").unwrap().version,
475            "1.0",
476            "the earlier of two same-named slices answers"
477        );
478        assert_eq!(shadowed.slices().len(), 2, "neither is dropped");
479    }
480
481    /// Union semantics without a bus: dirs fill everything, nothing claimed
482    /// from the bus, no invented disagreements.
483    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
484    async fn union_degrades_to_dirs_when_the_bus_is_silent() {
485        let session = crate::bus::session::open(&[], &[], false).await.unwrap();
486        let dir =
487            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixture-tests/registry");
488        let out = SliceSet::from_union(
489            &crate::Fleet::new(&session, ""),
490            &[dir],
491            std::time::Duration::from_millis(200),
492        )
493        .await
494        .unwrap();
495        assert!(out.from_bus.is_empty(), "no bus answered");
496        assert!(!out.dirs_only.is_empty(), "dirs supplied the slices");
497        assert!(out.disagreements.is_empty());
498        assert_eq!(out.set.slices().len(), out.dirs_only.len());
499    }
500
501    fn set(toml: &str) -> SliceSet {
502        SliceSet::from_slices(vec![zenkey::parse_slice(toml).unwrap()])
503    }
504
505    const SERVED: &str = r#"
506[registry]
507version = "2.0"
508app = "t"
509convention = 1
510[producer]
511name = "netring"
512[[subject]]
513path = "flows"
514class = "telemetry"
515type = "TelemetryPoint"
516[[subject]]
517path = "brand/new"
518class = "telemetry"
519type = "TelemetryPoint"
520"#;
521
522    const LOCAL: &str = r#"
523[registry]
524version = "1.0"
525app = "t"
526convention = 1
527[producer]
528name = "netring"
529[[subject]]
530path = "flows"
531class = "telemetry"
532type = "TelemetryPoint"
533"#;
534
535    /// The diff reports exactly the edited subject, plus the version skew —
536    /// #50's acceptance, without a bus.
537    #[test]
538    fn the_diff_names_the_one_subject_that_moved() {
539        let report = set(SERVED).diff(&set(LOCAL));
540        assert_eq!(report.producers.len(), 1);
541        let p = &report.producers[0];
542        assert_eq!(p.served_version.as_deref(), Some("2.0"));
543        assert_eq!(p.local_version.as_deref(), Some("1.0"));
544        assert!(
545            p.findings.iter().any(|f| f.contains("brand/new")),
546            "{:?}",
547            p.findings
548        );
549        assert!(
550            p.findings.iter().any(|f| f.contains("2.0")),
551            "the version skew is a finding too: {:?}",
552            p.findings
553        );
554    }
555
556    /// One-sided presence is a fact with a reason, never an error — and the
557    /// two sides read differently.
558    #[test]
559    fn one_sided_producers_explain_themselves() {
560        let empty = SliceSet::from_slices(vec![]);
561        let served_only = set(SERVED).diff(&empty);
562        assert!(served_only.producers[0].findings[0].contains("absent from the local registry"));
563        assert!(served_only.producers[0].local_version.is_none());
564
565        let local_only = empty.diff(&set(LOCAL));
566        assert!(local_only.producers[0].findings[0].contains("silence is not a verdict"));
567        assert!(local_only.producers[0].served_version.is_none());
568    }
569}