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::{Asked, CollapsedProducer, 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    /// Producers more than one origin answered for, and whether they agreed
58    /// (#385) — `NotAsked` for a set built from files or from bare slices,
59    /// which have no origin to collapse (#399). See
60    /// [`collapsed`](Self::collapsed).
61    collapsed: Asked<Vec<CollapsedProducer>>,
62}
63
64/// Group one slice's subjects by class, parsing each pattern once. A subject
65/// whose pattern does not parse is dropped here exactly as it was dropped
66/// per-call before — a malformed declaration refines nothing.
67fn parse_subjects(slice: &RegistrySlice) -> std::collections::BTreeMap<String, ParsedSubjects> {
68    let mut out: std::collections::BTreeMap<String, ParsedSubjects> = Default::default();
69    for (i, s) in slice.subjects.iter().enumerate() {
70        if let Ok(p) = zenkey::pattern::SubjectPattern::parse(&s.path) {
71            let entry = out.entry(s.class.token().to_string()).or_default();
72            entry.idx.push(i);
73            entry.pats.push(p);
74        }
75    }
76    out
77}
78
79impl SliceSet {
80    /// Load from local `registry/*.toml` dirs — the offline source. What a
81    /// checked-out application *declares*. (`types.toml` is the type table,
82    /// not a slice — skipped.)
83    pub fn from_dirs(dirs: &[PathBuf]) -> Result<SliceSet> {
84        let mut set = SliceSet::default();
85        for dir in dirs {
86            let mut paths: Vec<_> = std::fs::read_dir(dir)
87                .map_err(|e| Error::io(dir, e))?
88                .filter_map(|e| e.ok().map(|e| e.path()))
89                .filter(|p| p.extension().is_some_and(|e| e == "toml"))
90                .filter(|p| p.file_name().is_none_or(|n| n != "types.toml"))
91                .collect();
92            paths.sort();
93            for path in paths {
94                let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
95                let slice = parse_slice(&text)
96                    .map_err(|e| Error::malformed_from(path.display().to_string(), e))?;
97                set.push(slice, text);
98            }
99        }
100        Ok(set)
101    }
102
103    /// Discover every live producer's served slice from the bus
104    /// ([`crate::fleet_registry_by_origin`]), collapsed to one slice per
105    /// producer.
106    ///
107    /// The collapse is recorded rather than silent — see
108    /// [`collapsed`](Self::collapsed). A caller whose question is *which
109    /// host* should not come here at all: go one layer down to
110    /// [`crate::fleet_registry_by_origin`], which does not deduplicate.
111    pub async fn from_bus(fleet: &crate::Fleet<'_>, timeout: Duration) -> Result<SliceSet> {
112        Ok(SliceSet::from_served(
113            crate::bus::query::fleet_registry_by_origin(fleet, timeout).await?,
114        ))
115    }
116
117    /// Fold an origin-attributed sweep into one slice per producer, keeping
118    /// a receipt of what the fold discarded (#385).
119    ///
120    /// Pure, so the collapse is testable without a bus — and separable, so a
121    /// caller that ran its own sweep can reuse the fold without re-querying.
122    pub fn from_served(served: Vec<crate::ServedSlice>) -> SliceSet {
123        // name -> (origins, versions, the first raw text, still-agreeing)
124        let mut answers: std::collections::BTreeMap<
125            String,
126            (Vec<String>, Vec<String>, String, bool),
127        > = Default::default();
128        let mut set = SliceSet::default();
129        for s in served {
130            let entry = answers
131                .entry(s.slice.name.clone())
132                .or_insert_with(|| (Vec::new(), Vec::new(), s.raw.clone(), true));
133            entry.0.push(s.origin);
134            entry.1.push(s.slice.version.clone());
135            // Byte equality of the served TOML, not of the parse: two builds
136            // that differ only in a comment still differ, and a set that
137            // called them equal would be guessing.
138            if s.raw != entry.2 {
139                entry.3 = false;
140            }
141            set.push(s.slice, s.raw);
142        }
143        // `Asked` even when the fold discarded nothing: a bus sweep in which
144        // every producer had one origin *has* asked, and must not read like a
145        // set built from files that never could (#399, RFC 13 §3 O4).
146        set.collapsed = Asked::Asked(
147            answers
148                .into_iter()
149                // One answer is not a collapse.
150                .filter(|(_, (origins, ..))| origins.len() > 1)
151                .map(
152                    |(producer, (origins, versions, _, agreed))| CollapsedProducer {
153                        producer,
154                        origins,
155                        versions,
156                        agreed,
157                    },
158                )
159                .collect(),
160        );
161        set
162    }
163
164    /// Producers this set folded more than one origin's answer into, and
165    /// whether those origins agreed (#385).
166    ///
167    /// Three-state on purpose (#399). `NotAsked` is a set built from files or
168    /// from bare slices: neither carries an origin, so nothing *could* be
169    /// collapsed and no question was put. `Asked(&[])` is a bus sweep in
170    /// which every producer had exactly one origin answer — the fleet agrees,
171    /// and it agrees because it was asked. A bare empty slice conflated the
172    /// two, which is the O4 failure this receipt exists to avoid
173    /// (RFC 13 §3 O4).
174    pub fn collapsed(&self) -> Asked<&[CollapsedProducer]> {
175        match &self.collapsed {
176            Asked::NotAsked => Asked::NotAsked,
177            Asked::Asked(v) => Asked::Asked(v.as_slice()),
178        }
179    }
180
181    fn push(&mut self, slice: RegistrySlice, raw: String) {
182        // One slice per base name; last one wins (a fleet mid-rollout serves
183        // several versions — the newest reply is as good a pick as any, and
184        // `doctor` is where disagreement is *reported*). The discard is no
185        // longer silent on the bus path: `from_served` records who answered
186        // and whether they agreed, in `collapsed` (#385).
187        let parsed = parse_subjects(&slice);
188        if let Some(&i) = self.by_name.get(&slice.name) {
189            self.slices[i] = slice;
190            self.raw[i] = raw;
191            self.parsed[i] = parsed;
192        } else {
193            self.by_name.insert(slice.name.clone(), self.slices.len());
194            self.slices.push(slice);
195            self.raw.push(raw);
196            self.parsed.push(parsed);
197        }
198    }
199
200    /// Each slice with the raw TOML it was parsed from — the pair
201    /// `write_cache` persists. The text is empty for a set built by
202    /// [`from_slices`](Self::from_slices), which has none to give.
203    pub fn entries(&self) -> impl Iterator<Item = (&RegistrySlice, &str)> {
204        self.slices.iter().zip(self.raw.iter().map(String::as_str))
205    }
206
207    pub fn slices(&self) -> &[RegistrySlice] {
208        &self.slices
209    }
210
211    pub fn get(&self, name: &str) -> Option<&RegistrySlice> {
212        self.by_name.get(name).map(|&i| &self.slices[i])
213    }
214
215    /// The slice declaring a service origin (`@catalog`) — service keys have
216    /// no producer chunk, so refinement resolves through this.
217    pub fn by_service_origin(&self, origin: &str) -> Option<&RegistrySlice> {
218        self.slices
219            .iter()
220            .find(|s| s.service_origin.as_ref().map(Declared::token) == Some(origin))
221    }
222
223    /// Refine a subject tail against one producer's slice: the matching
224    /// subject declaration plus its named variable bindings.
225    pub fn refine<'s>(
226        &'s self,
227        producer: &str,
228        class: &str,
229        tail: &[&str],
230    ) -> Option<(&'s zenkey::slice::SubjectDecl, Vec<(String, String)>)> {
231        let i = *self.by_name.get(producer)?;
232        let slice = &self.slices[i];
233        // Precedence-ordered via the shared matcher (issue #7): the class's
234        // patterns were parsed at construction, so this is a map lookup and a
235        // borrowed slice — no parse, no clone, per sample.
236        let candidates = self.parsed[i].get(class)?;
237        let (winner, binds) = zenkey::pattern::best_match(&candidates.pats, tail)?;
238        let subject_idx = candidates.idx[winner];
239        Some((
240            &slice.subjects[subject_idx],
241            binds.into_iter().map(|(n, v)| (n.to_string(), v)).collect(),
242        ))
243    }
244
245    /// Build from already-parsed slices (no raw TOML retained — such a set
246    /// is skipped by `write_cache`).
247    pub fn from_slices(slices: Vec<RegistrySlice>) -> SliceSet {
248        let raw = vec![String::new(); slices.len()];
249        let parsed = slices.iter().map(parse_subjects).collect();
250        // `or_insert`, not `insert`: first wins, which is what the linear
251        // `find` this replaced did with a duplicated name.
252        let mut by_name = std::collections::BTreeMap::new();
253        for (i, s) in slices.iter().enumerate() {
254            by_name.entry(s.name.clone()).or_insert(i);
255        }
256        SliceSet {
257            slices,
258            raw,
259            parsed,
260            by_name,
261            // Bare slices carry no origin, so nothing here *could* be
262            // collapsed across origins — a duplicated name shadows (first
263            // wins), and that is a different fact from a fleet disagreeing
264            // (#385). Not asked, therefore, and not "asked and agreed" (#399).
265            collapsed: Asked::NotAsked,
266        }
267    }
268
269    /// Write the raw slice TOMLs to a cache dir (one file per producer).
270    /// Repeated invocations and dynamic shell completion read this instead
271    /// of round-tripping the bus.
272    pub fn write_cache(&self, dir: &Path) -> Result<()> {
273        std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
274        for (slice, raw) in self.slices.iter().zip(&self.raw) {
275            if raw.is_empty() {
276                continue; // from_slices sets: nothing faithful to persist
277            }
278            let path = dir.join(format!("{}.toml", slice.name));
279            std::fs::write(&path, raw).map_err(|e| Error::io(&path, e))?;
280        }
281        Ok(())
282    }
283
284    /// Read a previously written cache dir. Same forgiving posture as
285    /// `from_dirs`, but a missing dir is an empty set, not an error.
286    pub fn read_cache(dir: &Path) -> SliceSet {
287        if !dir.is_dir() {
288            return SliceSet::default();
289        }
290        SliceSet::from_dirs(&[dir.to_path_buf()]).unwrap_or_default()
291    }
292}
293
294/// Where a slice set came from — the §6.1 decision made typed: `--registry`
295/// and the bus stop being exclusive.
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum SliceSource {
298    Bus,
299    Dirs,
300    Union,
301}
302
303/// A union load's full outcome.
304#[derive(Debug, Clone)]
305pub struct UnionOutcome {
306    pub set: SliceSet,
307    /// Producers whose slice came from the bus.
308    pub from_bus: Vec<String>,
309    /// Producers only the dirs supplied.
310    pub dirs_only: Vec<String>,
311    pub disagreements: Vec<SliceDisagreement>,
312}
313
314impl SliceSet {
315    /// Load the union of the live bus and local dirs: **served wins per
316    /// producer**, dirs fill the gaps, and every producer where the two
317    /// disagree is retained as a [`SliceDisagreement`].
318    ///
319    /// Degrades honestly: an unreachable bus yields a dirs-only union (the
320    /// outcome's `from_bus` is empty — the caller can see which case it got).
321    pub async fn from_union(
322        fleet: &crate::Fleet<'_>,
323        dirs: &[std::path::PathBuf],
324        timeout: std::time::Duration,
325    ) -> Result<UnionOutcome> {
326        let bus = SliceSet::from_bus(fleet, timeout).await.unwrap_or_default();
327        let disk = if dirs.is_empty() {
328            SliceSet::default()
329        } else {
330            SliceSet::from_dirs(dirs)?
331        };
332
333        // Carry each slice's raw TOML through the merge (issue #54): a union
334        // that dropped it produced a set `write_cache` silently skipped, so
335        // the `--registry` path — the offline one, where a warm completion
336        // cache matters most — cached nothing at all.
337        let mut merged = SliceSet::default();
338        let mut from_bus = Vec::new();
339        let mut dirs_only = Vec::new();
340        let mut disagreements = Vec::new();
341
342        for (served, raw) in bus.entries() {
343            from_bus.push(served.name.clone());
344            if let Some(local) = disk.get(&served.name)
345                && (local.version != served.version || local != served)
346            {
347                disagreements.push(SliceDisagreement {
348                    producer: served.name.clone(),
349                    bus_version: served.version.clone(),
350                    dirs_version: local.version.clone(),
351                    shape_differs: {
352                        // Same version but different content is the worse lie.
353                        let mut a = served.clone();
354                        let mut b = local.clone();
355                        a.version = String::new();
356                        b.version = String::new();
357                        a != b
358                    },
359                });
360            }
361            merged.push(served.clone(), raw.to_string());
362        }
363        for (local, raw) in disk.entries() {
364            if bus.get(&local.name).is_none() {
365                dirs_only.push(local.name.clone());
366                merged.push(local.clone(), raw.to_string());
367            }
368        }
369        // The union is built fresh, so the bus set's receipt has to ride
370        // across or it is lost exactly where it matters most (#385): this is
371        // the constructor both explorers actually call. A dirs-only producer
372        // adds nothing to it — a file has no origin to disagree with.
373        merged.collapsed = bus.collapsed;
374
375        Ok(UnionOutcome {
376            set: merged,
377            from_bus,
378            dirs_only,
379            disagreements,
380        })
381    }
382}
383
384impl SliceSet {
385    /// Compare this set — what the fleet **serves** — against what a checkout
386    /// **declares**, per producer.
387    ///
388    /// Pure, so the comparison is testable without a bus, and engine-side so
389    /// both explorers can make it (issue #208). The per-producer comparison
390    /// is already `zenkey::slice::diff`; this is the set-level join that
391    /// decides what to do about a producer only one side knows.
392    pub fn diff(&self, local: &SliceSet) -> RegistryDiff {
393        let served = self;
394        let mut names: Vec<&str> = served
395            .slices()
396            .iter()
397            .chain(local.slices())
398            .map(|s| s.name.as_str())
399            .collect();
400        names.sort_unstable();
401        names.dedup();
402
403        let mut producers = Vec::new();
404        for name in names {
405            let s = served.get(name);
406            let l = local.get(name);
407            producers.push(match (s, l) {
408                (Some(s), Some(l)) => ProducerDiff {
409                    producer: name.to_string(),
410                    served_version: Some(s.version.clone()),
411                    local_version: Some(l.version.clone()),
412                    findings: zenkey::slice::diff(s, l)
413                        .iter()
414                        .map(|f| f.summary())
415                        .collect(),
416                },
417                // Present on one side only. Neither is an error: a producer the
418                // bus serves and the checkout does not know may simply be newer,
419                // and one the checkout declares that nothing serves may simply be
420                // down (RFC 05 §3.1 — silence is not a verdict).
421                (Some(s), None) => ProducerDiff {
422                    producer: name.to_string(),
423                    served_version: Some(s.version.clone()),
424                    local_version: None,
425                    findings: vec!["served by the fleet, absent from the local registry".into()],
426                },
427                (None, Some(l)) => ProducerDiff {
428                    producer: name.to_string(),
429                    served_version: None,
430                    local_version: Some(l.version.clone()),
431                    findings: vec![
432                        "declared locally, not served by any origin — down, or not deployed \
433                         (silence is not a verdict, RFC 05 §3.1)"
434                            .into(),
435                    ],
436                },
437                (None, None) => unreachable!("name came from one of the two sets"),
438            });
439        }
440        RegistryDiff {
441            producers,
442            // The receipt of the fold that produced `served` (#399). Carried
443            // rather than recomputed: the diff above is *already* built from
444            // one slice per producer, so this is the record of what that cost.
445            collapsed: match served.collapsed() {
446                Asked::NotAsked => Asked::NotAsked,
447                Asked::Asked(c) => Asked::Asked(c.to_vec()),
448            },
449        }
450    }
451}
452
453/// The `@rpc` key a slice's procedure is asked at — a service origin's
454/// verbatim `@` chunk is structurally unmatchable by a fleet selector's `*`
455/// (property D4), so it takes its own key. That is the grammar working, not
456/// an exception to it.
457///
458/// Pure: it reads the slice and spells a key, which is why it lives in the
459/// model and not beside the sweep that sends it (#410) — `bus/` may lean on
460/// `model/`, never the other way round, and `judge/` on both. It used to be
461/// private to the doctor, which meant the describe sweep could not leave the
462/// doctor without dragging the judge layer into the bus.
463// Both callers (the describe sweep and the doctor) are decode-gated, so
464// without the feature the function would be dead code and a warning.
465#[cfg(feature = "decode")]
466pub(crate) fn rpc_key(base: &str, slice: &RegistrySlice, procedure: &str) -> Result<String> {
467    Ok(match &slice.service_origin {
468        Some(origin) => {
469            // The slice already validated it on parse — `Other` here means the
470            // chunk is not a legal verbatim origin, which is the same finding
471            // the hand-rolled `ServiceOrigin::new` used to report.
472            // A *served* slice said this, so it is the peer that is
473            // malformed — not the caller, and not the fabric.
474            let o = origin.known().ok_or_else(|| {
475                Error::malformed(
476                    format!("slice {}", slice.name),
477                    format!("carries {:?} as a service origin", origin.token()),
478                )
479            })?;
480            zenkey::grammar::with_base(base, zenkey::selector::service_rpc(o, &[procedure]))
481        }
482        None => {
483            zenkey::grammar::with_base(base, zenkey::selector::fleet_rpc(&slice.name, &[procedure]))
484        }
485    })
486}
487
488#[cfg(test)]
489impl SliceSet {
490    /// Test constructor from one slice TOML (crate-internal).
491    pub(crate) fn from_toml_for_tests(toml: &str) -> SliceSet {
492        let mut set = SliceSet::default();
493        set.push(parse_slice(toml).unwrap(), toml.to_string());
494        set
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    const A: &str = r#"
503        [registry]
504        version = "1.0"
505        app = "t"
506        convention = 1
507        [producer]
508        name = "alpha"
509        [[subject]]
510        path = "flow/{q}"
511        class = "telemetry"
512        type = "Point"
513        [[subject]]
514        path = "flow/special"
515        class = "telemetry"
516        type = "Special"
517    "#;
518
519    #[test]
520    fn refine_uses_shared_precedence() {
521        let mut set = SliceSet::default();
522        set.push(parse_slice(A).unwrap(), A.to_string());
523        // Literal beats {var} — the shared best_match ordering.
524        let (s, binds) = set
525            .refine("alpha", "telemetry", &["flow", "special"])
526            .unwrap();
527        assert_eq!(s.type_name, "Special");
528        assert!(binds.is_empty());
529        let (s, binds) = set.refine("alpha", "telemetry", &["flow", "p95"]).unwrap();
530        assert_eq!(s.type_name, "Point");
531        assert_eq!(binds, vec![("q".to_string(), "p95".to_string())]);
532        assert!(set.refine("alpha", "state", &["flow", "p95"]).is_none());
533    }
534
535    /// The fold to one-slice-per-producer keeps a receipt of what it
536    /// discarded (#385).
537    ///
538    /// The collapse itself is right — a decoder refining a key needs *a*
539    /// slice per producer and does not care which host served it. What was
540    /// wrong is that the resulting set looked complete while being one
541    /// arbitrary host's answer, so a `diff` computed from it read as
542    /// fleet-wide truth.
543    #[test]
544    fn the_fold_to_one_slice_per_producer_records_what_it_discarded() {
545        let served = |origin: &str, raw: &str| crate::ServedSlice {
546            origin: origin.to_string(),
547            slice: parse_slice(raw).unwrap(),
548            raw: raw.to_string(),
549        };
550
551        // Two hosts, one producer, disagreeing bodies: a fleet mid-rollout.
552        let mut b_variant = A.to_string();
553        b_variant.push_str(
554            "\n[[subject]]\npath = \"extra\"\nclass = \"state\"\ntype = \"E\"\nttl_s = 1\n",
555        );
556        let set = SliceSet::from_served(vec![
557            served("h-aaaaaaaaaaaa", A),
558            served("h-bbbbbbbbbbbb", &b_variant),
559        ]);
560        assert_eq!(set.slices().len(), 1, "still one slice per producer");
561
562        let collapsed = set
563            .collapsed()
564            .as_option()
565            .copied()
566            .expect("a bus fold asked");
567        assert_eq!(collapsed.len(), 1, "{collapsed:?}");
568        assert_eq!(collapsed[0].producer, "alpha");
569        assert_eq!(
570            collapsed[0].origins,
571            vec!["h-aaaaaaaaaaaa", "h-bbbbbbbbbbbb"]
572        );
573        assert!(
574            !collapsed[0].agreed,
575            "the discarded answer differed — that is the finding"
576        );
577
578        // Agreement is a fact about the answers, not about how many replied.
579        let agreeing = SliceSet::from_served(vec![
580            served("h-aaaaaaaaaaaa", A),
581            served("h-bbbbbbbbbbbb", A),
582        ]);
583        assert!(agreeing.collapsed().as_option().copied().expect("asked")[0].agreed);
584
585        // One answer is not a collapse — but the sweep *asked*, and the two
586        // zeros are not the same zero (#399, RFC 13 §3 O4).
587        let one_host = SliceSet::from_served(vec![served("h-aaaaaaaaaaaa", A)]);
588        assert_eq!(
589            one_host.collapsed(),
590            Asked::Asked(&[][..]),
591            "asked, and nothing was collapsed"
592        );
593        // A set with no origins to collapse never could have been asked.
594        assert_eq!(
595            SliceSet::from_slices(vec![parse_slice(A).unwrap()]).collapsed(),
596            Asked::NotAsked,
597            "not asked is not \"the fleet agrees\""
598        );
599        assert_eq!(SliceSet::default().collapsed(), Asked::NotAsked);
600    }
601
602    #[test]
603    fn cache_round_trips_and_last_slice_wins() {
604        let mut set = SliceSet::default();
605        set.push(parse_slice(A).unwrap(), A.to_string());
606        // A newer slice for the same producer replaces, never duplicates.
607        set.push(parse_slice(A).unwrap(), A.to_string());
608        assert_eq!(set.slices().len(), 1);
609
610        let dir = std::env::temp_dir().join(format!("zenkey-fleet-cache-{}", std::process::id()));
611        let _ = std::fs::remove_dir_all(&dir);
612        set.write_cache(&dir).unwrap();
613        let back = SliceSet::read_cache(&dir);
614        assert_eq!(back.slices().len(), 1);
615        assert_eq!(back.get("alpha").unwrap().subjects.len(), 2);
616        let _ = std::fs::remove_dir_all(&dir);
617        // Missing dir: empty set, not an error.
618        assert!(
619            SliceSet::read_cache(Path::new("/nonexistent-zkf"))
620                .slices()
621                .is_empty()
622        );
623    }
624
625    /// The name index answers exactly what the linear scan answered.
626    ///
627    /// Two rules, both observable, both easy to lose to a map: a re-pushed
628    /// producer replaces **in place** (so `slices()` order is stable and the
629    /// newest slice is the one that refines), and a set built through
630    /// [`SliceSet::from_slices`] — which does not go through `push` — can
631    /// hold the same name twice, where the **first** answers.
632    #[test]
633    fn a_re_pushed_producer_keeps_its_place_and_shadowing_is_first_wins() {
634        let newer = A.replace("version = \"1.0\"", "version = \"9.9\"");
635        let other = A.replace("name = \"alpha\"", "name = \"beta\"");
636
637        let mut set = SliceSet::default();
638        set.push(parse_slice(A).unwrap(), A.to_string());
639        set.push(parse_slice(&other).unwrap(), other.clone());
640        set.push(parse_slice(&newer).unwrap(), newer.clone());
641
642        assert_eq!(set.slices().len(), 2, "a re-push replaces, never appends");
643        assert_eq!(
644            set.slices()[0].name,
645            "alpha",
646            "the replacement keeps the producer's position"
647        );
648        assert_eq!(set.get("alpha").unwrap().version, "9.9", "last push wins");
649        assert_eq!(
650            set.entries().next().unwrap().1,
651            newer,
652            "the raw TOML rides with the slice it was parsed from"
653        );
654        assert!(set.get("gamma").is_none());
655        // …and refinement still resolves through the replaced slice.
656        assert_eq!(
657            set.refine("alpha", "telemetry", &["flow", "special"])
658                .unwrap()
659                .0
660                .type_name,
661            "Special"
662        );
663        assert!(set.refine("gamma", "telemetry", &["flow"]).is_none());
664
665        // The shadowing `from_slices` can produce: first wins, both ways.
666        let shadowed =
667            SliceSet::from_slices(vec![parse_slice(A).unwrap(), parse_slice(&newer).unwrap()]);
668        assert_eq!(
669            shadowed.get("alpha").unwrap().version,
670            "1.0",
671            "the earlier of two same-named slices answers"
672        );
673        assert_eq!(shadowed.slices().len(), 2, "neither is dropped");
674    }
675
676    /// Union semantics without a bus: dirs fill everything, nothing claimed
677    /// from the bus, no invented disagreements.
678    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
679    async fn union_degrades_to_dirs_when_the_bus_is_silent() {
680        let session = crate::bus::session::open(&[], &[], false).await.unwrap();
681        let dir =
682            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixture-tests/registry");
683        let out = SliceSet::from_union(
684            &crate::Fleet::new(&session, ""),
685            &[dir],
686            std::time::Duration::from_millis(200),
687        )
688        .await
689        .unwrap();
690        assert!(out.from_bus.is_empty(), "no bus answered");
691        assert!(!out.dirs_only.is_empty(), "dirs supplied the slices");
692        assert!(out.disagreements.is_empty());
693        assert_eq!(out.set.slices().len(), out.dirs_only.len());
694    }
695
696    fn set(toml: &str) -> SliceSet {
697        SliceSet::from_slices(vec![zenkey::parse_slice(toml).unwrap()])
698    }
699
700    const SERVED: &str = r#"
701[registry]
702version = "2.0"
703app = "t"
704convention = 1
705[producer]
706name = "netring"
707[[subject]]
708path = "flows"
709class = "telemetry"
710type = "TelemetryPoint"
711[[subject]]
712path = "brand/new"
713class = "telemetry"
714type = "TelemetryPoint"
715"#;
716
717    const LOCAL: &str = r#"
718[registry]
719version = "1.0"
720app = "t"
721convention = 1
722[producer]
723name = "netring"
724[[subject]]
725path = "flows"
726class = "telemetry"
727type = "TelemetryPoint"
728"#;
729
730    /// The diff reports exactly the edited subject, plus the version skew —
731    /// #50's acceptance, without a bus.
732    #[test]
733    fn the_diff_names_the_one_subject_that_moved() {
734        let report = set(SERVED).diff(&set(LOCAL));
735        assert_eq!(report.producers.len(), 1);
736        let p = &report.producers[0];
737        assert_eq!(p.served_version.as_deref(), Some("2.0"));
738        assert_eq!(p.local_version.as_deref(), Some("1.0"));
739        assert!(
740            p.findings.iter().any(|f| f.contains("brand/new")),
741            "{:?}",
742            p.findings
743        );
744        assert!(
745            p.findings.iter().any(|f| f.contains("2.0")),
746            "the version skew is a finding too: {:?}",
747            p.findings
748        );
749    }
750
751    /// One-sided presence is a fact with a reason, never an error — and the
752    /// two sides read differently.
753    #[test]
754    fn one_sided_producers_explain_themselves() {
755        let empty = SliceSet::from_slices(vec![]);
756        let served_only = set(SERVED).diff(&empty);
757        assert!(served_only.producers[0].findings[0].contains("absent from the local registry"));
758        assert!(served_only.producers[0].local_version.is_none());
759
760        let local_only = empty.diff(&set(LOCAL));
761        assert!(local_only.producers[0].findings[0].contains("silence is not a verdict"));
762        assert!(local_only.producers[0].served_version.is_none());
763    }
764}