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#[cfg(test)]
454impl SliceSet {
455    /// Test constructor from one slice TOML (crate-internal).
456    pub(crate) fn from_toml_for_tests(toml: &str) -> SliceSet {
457        let mut set = SliceSet::default();
458        set.push(parse_slice(toml).unwrap(), toml.to_string());
459        set
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    const A: &str = r#"
468        [registry]
469        version = "1.0"
470        app = "t"
471        convention = 1
472        [producer]
473        name = "alpha"
474        [[subject]]
475        path = "flow/{q}"
476        class = "telemetry"
477        type = "Point"
478        [[subject]]
479        path = "flow/special"
480        class = "telemetry"
481        type = "Special"
482    "#;
483
484    #[test]
485    fn refine_uses_shared_precedence() {
486        let mut set = SliceSet::default();
487        set.push(parse_slice(A).unwrap(), A.to_string());
488        // Literal beats {var} — the shared best_match ordering.
489        let (s, binds) = set
490            .refine("alpha", "telemetry", &["flow", "special"])
491            .unwrap();
492        assert_eq!(s.type_name, "Special");
493        assert!(binds.is_empty());
494        let (s, binds) = set.refine("alpha", "telemetry", &["flow", "p95"]).unwrap();
495        assert_eq!(s.type_name, "Point");
496        assert_eq!(binds, vec![("q".to_string(), "p95".to_string())]);
497        assert!(set.refine("alpha", "state", &["flow", "p95"]).is_none());
498    }
499
500    /// The fold to one-slice-per-producer keeps a receipt of what it
501    /// discarded (#385).
502    ///
503    /// The collapse itself is right — a decoder refining a key needs *a*
504    /// slice per producer and does not care which host served it. What was
505    /// wrong is that the resulting set looked complete while being one
506    /// arbitrary host's answer, so a `diff` computed from it read as
507    /// fleet-wide truth.
508    #[test]
509    fn the_fold_to_one_slice_per_producer_records_what_it_discarded() {
510        let served = |origin: &str, raw: &str| crate::ServedSlice {
511            origin: origin.to_string(),
512            slice: parse_slice(raw).unwrap(),
513            raw: raw.to_string(),
514        };
515
516        // Two hosts, one producer, disagreeing bodies: a fleet mid-rollout.
517        let mut b_variant = A.to_string();
518        b_variant.push_str(
519            "\n[[subject]]\npath = \"extra\"\nclass = \"state\"\ntype = \"E\"\nttl_s = 1\n",
520        );
521        let set = SliceSet::from_served(vec![
522            served("h-aaaaaaaaaaaa", A),
523            served("h-bbbbbbbbbbbb", &b_variant),
524        ]);
525        assert_eq!(set.slices().len(), 1, "still one slice per producer");
526
527        let collapsed = set
528            .collapsed()
529            .as_option()
530            .copied()
531            .expect("a bus fold asked");
532        assert_eq!(collapsed.len(), 1, "{collapsed:?}");
533        assert_eq!(collapsed[0].producer, "alpha");
534        assert_eq!(
535            collapsed[0].origins,
536            vec!["h-aaaaaaaaaaaa", "h-bbbbbbbbbbbb"]
537        );
538        assert!(
539            !collapsed[0].agreed,
540            "the discarded answer differed — that is the finding"
541        );
542
543        // Agreement is a fact about the answers, not about how many replied.
544        let agreeing = SliceSet::from_served(vec![
545            served("h-aaaaaaaaaaaa", A),
546            served("h-bbbbbbbbbbbb", A),
547        ]);
548        assert!(agreeing.collapsed().as_option().copied().expect("asked")[0].agreed);
549
550        // One answer is not a collapse — but the sweep *asked*, and the two
551        // zeros are not the same zero (#399, RFC 13 §3 O4).
552        let one_host = SliceSet::from_served(vec![served("h-aaaaaaaaaaaa", A)]);
553        assert_eq!(
554            one_host.collapsed(),
555            Asked::Asked(&[][..]),
556            "asked, and nothing was collapsed"
557        );
558        // A set with no origins to collapse never could have been asked.
559        assert_eq!(
560            SliceSet::from_slices(vec![parse_slice(A).unwrap()]).collapsed(),
561            Asked::NotAsked,
562            "not asked is not \"the fleet agrees\""
563        );
564        assert_eq!(SliceSet::default().collapsed(), Asked::NotAsked);
565    }
566
567    #[test]
568    fn cache_round_trips_and_last_slice_wins() {
569        let mut set = SliceSet::default();
570        set.push(parse_slice(A).unwrap(), A.to_string());
571        // A newer slice for the same producer replaces, never duplicates.
572        set.push(parse_slice(A).unwrap(), A.to_string());
573        assert_eq!(set.slices().len(), 1);
574
575        let dir = std::env::temp_dir().join(format!("zenkey-fleet-cache-{}", std::process::id()));
576        let _ = std::fs::remove_dir_all(&dir);
577        set.write_cache(&dir).unwrap();
578        let back = SliceSet::read_cache(&dir);
579        assert_eq!(back.slices().len(), 1);
580        assert_eq!(back.get("alpha").unwrap().subjects.len(), 2);
581        let _ = std::fs::remove_dir_all(&dir);
582        // Missing dir: empty set, not an error.
583        assert!(
584            SliceSet::read_cache(Path::new("/nonexistent-zkf"))
585                .slices()
586                .is_empty()
587        );
588    }
589
590    /// The name index answers exactly what the linear scan answered.
591    ///
592    /// Two rules, both observable, both easy to lose to a map: a re-pushed
593    /// producer replaces **in place** (so `slices()` order is stable and the
594    /// newest slice is the one that refines), and a set built through
595    /// [`SliceSet::from_slices`] — which does not go through `push` — can
596    /// hold the same name twice, where the **first** answers.
597    #[test]
598    fn a_re_pushed_producer_keeps_its_place_and_shadowing_is_first_wins() {
599        let newer = A.replace("version = \"1.0\"", "version = \"9.9\"");
600        let other = A.replace("name = \"alpha\"", "name = \"beta\"");
601
602        let mut set = SliceSet::default();
603        set.push(parse_slice(A).unwrap(), A.to_string());
604        set.push(parse_slice(&other).unwrap(), other.clone());
605        set.push(parse_slice(&newer).unwrap(), newer.clone());
606
607        assert_eq!(set.slices().len(), 2, "a re-push replaces, never appends");
608        assert_eq!(
609            set.slices()[0].name,
610            "alpha",
611            "the replacement keeps the producer's position"
612        );
613        assert_eq!(set.get("alpha").unwrap().version, "9.9", "last push wins");
614        assert_eq!(
615            set.entries().next().unwrap().1,
616            newer,
617            "the raw TOML rides with the slice it was parsed from"
618        );
619        assert!(set.get("gamma").is_none());
620        // …and refinement still resolves through the replaced slice.
621        assert_eq!(
622            set.refine("alpha", "telemetry", &["flow", "special"])
623                .unwrap()
624                .0
625                .type_name,
626            "Special"
627        );
628        assert!(set.refine("gamma", "telemetry", &["flow"]).is_none());
629
630        // The shadowing `from_slices` can produce: first wins, both ways.
631        let shadowed =
632            SliceSet::from_slices(vec![parse_slice(A).unwrap(), parse_slice(&newer).unwrap()]);
633        assert_eq!(
634            shadowed.get("alpha").unwrap().version,
635            "1.0",
636            "the earlier of two same-named slices answers"
637        );
638        assert_eq!(shadowed.slices().len(), 2, "neither is dropped");
639    }
640
641    /// Union semantics without a bus: dirs fill everything, nothing claimed
642    /// from the bus, no invented disagreements.
643    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
644    async fn union_degrades_to_dirs_when_the_bus_is_silent() {
645        let session = crate::bus::session::open(&[], &[], false).await.unwrap();
646        let dir =
647            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixture-tests/registry");
648        let out = SliceSet::from_union(
649            &crate::Fleet::new(&session, ""),
650            &[dir],
651            std::time::Duration::from_millis(200),
652        )
653        .await
654        .unwrap();
655        assert!(out.from_bus.is_empty(), "no bus answered");
656        assert!(!out.dirs_only.is_empty(), "dirs supplied the slices");
657        assert!(out.disagreements.is_empty());
658        assert_eq!(out.set.slices().len(), out.dirs_only.len());
659    }
660
661    fn set(toml: &str) -> SliceSet {
662        SliceSet::from_slices(vec![zenkey::parse_slice(toml).unwrap()])
663    }
664
665    const SERVED: &str = r#"
666[registry]
667version = "2.0"
668app = "t"
669convention = 1
670[producer]
671name = "netring"
672[[subject]]
673path = "flows"
674class = "telemetry"
675type = "TelemetryPoint"
676[[subject]]
677path = "brand/new"
678class = "telemetry"
679type = "TelemetryPoint"
680"#;
681
682    const LOCAL: &str = r#"
683[registry]
684version = "1.0"
685app = "t"
686convention = 1
687[producer]
688name = "netring"
689[[subject]]
690path = "flows"
691class = "telemetry"
692type = "TelemetryPoint"
693"#;
694
695    /// The diff reports exactly the edited subject, plus the version skew —
696    /// #50's acceptance, without a bus.
697    #[test]
698    fn the_diff_names_the_one_subject_that_moved() {
699        let report = set(SERVED).diff(&set(LOCAL));
700        assert_eq!(report.producers.len(), 1);
701        let p = &report.producers[0];
702        assert_eq!(p.served_version.as_deref(), Some("2.0"));
703        assert_eq!(p.local_version.as_deref(), Some("1.0"));
704        assert!(
705            p.findings.iter().any(|f| f.contains("brand/new")),
706            "{:?}",
707            p.findings
708        );
709        assert!(
710            p.findings.iter().any(|f| f.contains("2.0")),
711            "the version skew is a finding too: {:?}",
712            p.findings
713        );
714    }
715
716    /// One-sided presence is a fact with a reason, never an error — and the
717    /// two sides read differently.
718    #[test]
719    fn one_sided_producers_explain_themselves() {
720        let empty = SliceSet::from_slices(vec![]);
721        let served_only = set(SERVED).diff(&empty);
722        assert!(served_only.producers[0].findings[0].contains("absent from the local registry"));
723        assert!(served_only.producers[0].local_version.is_none());
724
725        let local_only = empty.diff(&set(LOCAL));
726        assert!(local_only.producers[0].findings[0].contains("silence is not a verdict"));
727        assert!(local_only.producers[0].served_version.is_none());
728    }
729}