Skip to main content

zenkey_fleet/
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 anyhow::{Result, anyhow};
13use zenkey::{RegistrySlice, parse_slice};
14use zenoh::Session;
15
16/// One slice's subject patterns, parsed once and grouped by class.
17///
18/// `refine` runs **per sample** on zenctl's decode path and per first-sight
19/// key in zengui, and it used to parse every subject pattern of the class on
20/// every call — then clone them all again to hand `best_match` a contiguous
21/// slice. Parsing at construction turns that into a map lookup
22/// (`docs/zero-copy.md`).
23#[derive(Debug, Clone, Default)]
24struct ParsedSubjects {
25    /// Index into the slice's own `subjects`, parallel to `pats`.
26    idx: Vec<usize>,
27    /// Contiguous, so `best_match` takes it borrowed.
28    pats: Vec<zenkey::pattern::SubjectPattern>,
29}
30
31/// A set of registry slices, indexed by producer/service base name.
32#[derive(Debug, Clone, Default)]
33pub struct SliceSet {
34    slices: Vec<RegistrySlice>,
35    /// The raw TOML per slice, kept for the disk cache (slices do not
36    /// re-serialize; the served text is the artifact).
37    raw: Vec<String>,
38    /// Parsed subject patterns per slice, keyed by class. Rebuilt wholesale
39    /// with its slice — the two vectors are index-parallel, and `push` is the
40    /// only place either grows.
41    parsed: Vec<std::collections::BTreeMap<String, ParsedSubjects>>,
42}
43
44/// Group one slice's subjects by class, parsing each pattern once. A subject
45/// whose pattern does not parse is dropped here exactly as it was dropped
46/// per-call before — a malformed declaration refines nothing.
47fn parse_subjects(slice: &RegistrySlice) -> std::collections::BTreeMap<String, ParsedSubjects> {
48    let mut out: std::collections::BTreeMap<String, ParsedSubjects> = Default::default();
49    for (i, s) in slice.subjects.iter().enumerate() {
50        if let Ok(p) = zenkey::pattern::SubjectPattern::parse(&s.path) {
51            let entry = out.entry(s.class.clone()).or_default();
52            entry.idx.push(i);
53            entry.pats.push(p);
54        }
55    }
56    out
57}
58
59impl SliceSet {
60    /// Load from local `registry/*.toml` dirs — the offline source. What a
61    /// checked-out application *declares*. (`types.toml` is the type table,
62    /// not a slice — skipped.)
63    pub fn from_dirs(dirs: &[PathBuf]) -> Result<SliceSet> {
64        let mut set = SliceSet::default();
65        for dir in dirs {
66            let mut paths: Vec<_> = std::fs::read_dir(dir)
67                .map_err(|e| anyhow!("--registry {}: {e}", dir.display()))?
68                .filter_map(|e| e.ok().map(|e| e.path()))
69                .filter(|p| p.extension().is_some_and(|e| e == "toml"))
70                .filter(|p| p.file_name().is_none_or(|n| n != "types.toml"))
71                .collect();
72            paths.sort();
73            for path in paths {
74                let text = std::fs::read_to_string(&path)
75                    .map_err(|e| anyhow!("{}: {e}", path.display()))?;
76                let slice = parse_slice(&text).map_err(|e| {
77                    anyhow!(
78                        "{}: does not parse as a registry slice: {e}",
79                        path.display()
80                    )
81                })?;
82                set.push(slice, text);
83            }
84        }
85        Ok(set)
86    }
87
88    /// Discover every live producer's served slice from the bus
89    /// ([`crate::query::fleet_registry`]).
90    pub async fn from_bus(session: &Session, base: &str, timeout: Duration) -> Result<SliceSet> {
91        let pairs = crate::query::fleet_registry_raw(session, base, timeout).await?;
92        let mut set = SliceSet::default();
93        for (slice, raw) in pairs {
94            set.push(slice, raw);
95        }
96        Ok(set)
97    }
98
99    fn push(&mut self, slice: RegistrySlice, raw: String) {
100        // One slice per base name; last one wins (a fleet mid-rollout serves
101        // several versions — the newest reply is as good a pick as any, and
102        // `doctor` is where disagreement is *reported*).
103        let parsed = parse_subjects(&slice);
104        if let Some(i) = self.slices.iter().position(|s| s.name == slice.name) {
105            self.slices[i] = slice;
106            self.raw[i] = raw;
107            self.parsed[i] = parsed;
108        } else {
109            self.slices.push(slice);
110            self.raw.push(raw);
111            self.parsed.push(parsed);
112        }
113    }
114
115    /// Each slice with the raw TOML it was parsed from — the pair
116    /// `write_cache` persists. The text is empty for a set built by
117    /// [`from_slices`](Self::from_slices), which has none to give.
118    pub fn entries(&self) -> impl Iterator<Item = (&RegistrySlice, &str)> {
119        self.slices.iter().zip(self.raw.iter().map(String::as_str))
120    }
121
122    pub fn slices(&self) -> &[RegistrySlice] {
123        &self.slices
124    }
125
126    pub fn get(&self, name: &str) -> Option<&RegistrySlice> {
127        self.slices.iter().find(|s| s.name == name)
128    }
129
130    /// The slice declaring a service origin (`@catalog`) — service keys have
131    /// no producer chunk, so refinement resolves through this.
132    pub fn by_service_origin(&self, origin: &str) -> Option<&RegistrySlice> {
133        self.slices
134            .iter()
135            .find(|s| s.service_origin.as_deref() == Some(origin))
136    }
137
138    /// Refine a subject tail against one producer's slice: the matching
139    /// subject declaration plus its named variable bindings.
140    pub fn refine<'s>(
141        &'s self,
142        producer: &str,
143        class: &str,
144        tail: &[&str],
145    ) -> Option<(&'s zenkey::slice::SubjectDecl, Vec<(String, String)>)> {
146        let i = self.slices.iter().position(|s| s.name == producer)?;
147        let slice = &self.slices[i];
148        // Precedence-ordered via the shared matcher (issue #7): the class's
149        // patterns were parsed at construction, so this is a map lookup and a
150        // borrowed slice — no parse, no clone, per sample.
151        let candidates = self.parsed[i].get(class)?;
152        let (winner, binds) = zenkey::pattern::best_match(&candidates.pats, tail)?;
153        let subject_idx = candidates.idx[winner];
154        Some((
155            &slice.subjects[subject_idx],
156            binds.into_iter().map(|(n, v)| (n.to_string(), v)).collect(),
157        ))
158    }
159
160    /// Build from already-parsed slices (no raw TOML retained — such a set
161    /// is skipped by `write_cache`).
162    pub fn from_slices(slices: Vec<RegistrySlice>) -> SliceSet {
163        let raw = vec![String::new(); slices.len()];
164        let parsed = slices.iter().map(parse_subjects).collect();
165        SliceSet {
166            slices,
167            raw,
168            parsed,
169        }
170    }
171
172    /// Write the raw slice TOMLs to a cache dir (one file per producer).
173    /// Repeated invocations and dynamic shell completion read this instead
174    /// of round-tripping the bus.
175    pub fn write_cache(&self, dir: &Path) -> Result<()> {
176        std::fs::create_dir_all(dir)?;
177        for (slice, raw) in self.slices.iter().zip(&self.raw) {
178            if raw.is_empty() {
179                continue; // from_slices sets: nothing faithful to persist
180            }
181            std::fs::write(dir.join(format!("{}.toml", slice.name)), raw)?;
182        }
183        Ok(())
184    }
185
186    /// Read a previously written cache dir. Same forgiving posture as
187    /// `from_dirs`, but a missing dir is an empty set, not an error.
188    pub fn read_cache(dir: &Path) -> SliceSet {
189        if !dir.is_dir() {
190            return SliceSet::default();
191        }
192        SliceSet::from_dirs(&[dir.to_path_buf()]).unwrap_or_default()
193    }
194}
195
196/// Where a slice set came from — the §6.1 decision made typed: `--registry`
197/// and the bus stop being exclusive.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum SliceSource {
200    Bus,
201    Dirs,
202    Union,
203}
204
205/// One producer where the served slice and the on-disk slice disagree.
206///
207/// A disagreement is **data**, not an error: served wins in the union (the
208/// bus is the runtime truth, RFC 08 §6.1), and the difference is retained for
209/// `doctor` to report instead of being silently overwritten.
210#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
211pub struct SliceDisagreement {
212    pub producer: String,
213    pub bus_version: String,
214    pub dirs_version: String,
215    /// Whether anything beyond the version string differs (subjects,
216    /// procedures, blob tiers).
217    pub shape_differs: bool,
218}
219
220/// A union load's full outcome.
221#[derive(Debug, Clone)]
222pub struct UnionOutcome {
223    pub set: SliceSet,
224    /// Producers whose slice came from the bus.
225    pub from_bus: Vec<String>,
226    /// Producers only the dirs supplied.
227    pub dirs_only: Vec<String>,
228    pub disagreements: Vec<SliceDisagreement>,
229}
230
231impl SliceSet {
232    /// Load the union of the live bus and local dirs: **served wins per
233    /// producer**, dirs fill the gaps, and every producer where the two
234    /// disagree is retained as a [`SliceDisagreement`].
235    ///
236    /// Degrades honestly: an unreachable bus yields a dirs-only union (the
237    /// outcome's `from_bus` is empty — the caller can see which case it got).
238    pub async fn from_union(
239        session: &zenoh::Session,
240        base: &str,
241        dirs: &[std::path::PathBuf],
242        timeout: std::time::Duration,
243    ) -> Result<UnionOutcome> {
244        let bus = SliceSet::from_bus(session, base, timeout)
245            .await
246            .unwrap_or_default();
247        let disk = if dirs.is_empty() {
248            SliceSet::default()
249        } else {
250            SliceSet::from_dirs(dirs)?
251        };
252
253        // Carry each slice's raw TOML through the merge (issue #54): a union
254        // that dropped it produced a set `write_cache` silently skipped, so
255        // the `--registry` path — the offline one, where a warm completion
256        // cache matters most — cached nothing at all.
257        let mut merged = SliceSet::default();
258        let mut from_bus = Vec::new();
259        let mut dirs_only = Vec::new();
260        let mut disagreements = Vec::new();
261
262        for (served, raw) in bus.entries() {
263            from_bus.push(served.name.clone());
264            if let Some(local) = disk.get(&served.name)
265                && (local.version != served.version || local != served)
266            {
267                disagreements.push(SliceDisagreement {
268                    producer: served.name.clone(),
269                    bus_version: served.version.clone(),
270                    dirs_version: local.version.clone(),
271                    shape_differs: {
272                        // Same version but different content is the worse lie.
273                        let mut a = served.clone();
274                        let mut b = local.clone();
275                        a.version = String::new();
276                        b.version = String::new();
277                        a != b
278                    },
279                });
280            }
281            merged.push(served.clone(), raw.to_string());
282        }
283        for (local, raw) in disk.entries() {
284            if bus.get(&local.name).is_none() {
285                dirs_only.push(local.name.clone());
286                merged.push(local.clone(), raw.to_string());
287            }
288        }
289
290        Ok(UnionOutcome {
291            set: merged,
292            from_bus,
293            dirs_only,
294            disagreements,
295        })
296    }
297}
298
299#[cfg(test)]
300impl SliceSet {
301    /// Test constructor from one slice TOML (crate-internal).
302    pub(crate) fn from_toml_for_tests(toml: &str) -> SliceSet {
303        let mut set = SliceSet::default();
304        set.push(parse_slice(toml).unwrap(), toml.to_string());
305        set
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    const A: &str = r#"
314        [registry]
315        version = "1.0"
316        app = "t"
317        convention = 1
318        [producer]
319        name = "alpha"
320        [[subject]]
321        path = "flow/{q}"
322        class = "telemetry"
323        type = "Point"
324        [[subject]]
325        path = "flow/special"
326        class = "telemetry"
327        type = "Special"
328    "#;
329
330    #[test]
331    fn refine_uses_shared_precedence() {
332        let mut set = SliceSet::default();
333        set.push(parse_slice(A).unwrap(), A.to_string());
334        // Literal beats {var} — the shared best_match ordering.
335        let (s, binds) = set
336            .refine("alpha", "telemetry", &["flow", "special"])
337            .unwrap();
338        assert_eq!(s.type_name, "Special");
339        assert!(binds.is_empty());
340        let (s, binds) = set.refine("alpha", "telemetry", &["flow", "p95"]).unwrap();
341        assert_eq!(s.type_name, "Point");
342        assert_eq!(binds, vec![("q".to_string(), "p95".to_string())]);
343        assert!(set.refine("alpha", "state", &["flow", "p95"]).is_none());
344    }
345
346    #[test]
347    fn cache_round_trips_and_last_slice_wins() {
348        let mut set = SliceSet::default();
349        set.push(parse_slice(A).unwrap(), A.to_string());
350        // A newer slice for the same producer replaces, never duplicates.
351        set.push(parse_slice(A).unwrap(), A.to_string());
352        assert_eq!(set.slices().len(), 1);
353
354        let dir = std::env::temp_dir().join(format!("zenkey-fleet-cache-{}", std::process::id()));
355        let _ = std::fs::remove_dir_all(&dir);
356        set.write_cache(&dir).unwrap();
357        let back = SliceSet::read_cache(&dir);
358        assert_eq!(back.slices().len(), 1);
359        assert_eq!(back.get("alpha").unwrap().subjects.len(), 2);
360        let _ = std::fs::remove_dir_all(&dir);
361        // Missing dir: empty set, not an error.
362        assert!(
363            SliceSet::read_cache(Path::new("/nonexistent-zkf"))
364                .slices()
365                .is_empty()
366        );
367    }
368
369    /// Union semantics without a bus: dirs fill everything, nothing claimed
370    /// from the bus, no invented disagreements.
371    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
372    async fn union_degrades_to_dirs_when_the_bus_is_silent() {
373        let session = crate::session::open(&[], &[], false).await.unwrap();
374        let dir =
375            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixture-tests/registry");
376        let out = SliceSet::from_union(&session, "", &[dir], std::time::Duration::from_millis(200))
377            .await
378            .unwrap();
379        assert!(out.from_bus.is_empty(), "no bus answered");
380        assert!(!out.dirs_only.is_empty(), "dirs supplied the slices");
381        assert!(out.disagreements.is_empty());
382        assert_eq!(out.set.slices().len(), out.dirs_only.len());
383    }
384}