1use 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#[derive(Debug, Clone, Default)]
25struct ParsedSubjects {
26 idx: Vec<usize>,
28 pats: Vec<zenkey::pattern::SubjectPattern>,
30}
31
32#[derive(Debug, Clone, Default)]
34pub struct SliceSet {
35 slices: Vec<RegistrySlice>,
36 raw: Vec<String>,
39 parsed: Vec<std::collections::BTreeMap<String, ParsedSubjects>>,
43 by_name: std::collections::BTreeMap<String, usize>,
57}
58
59fn 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 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 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 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 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 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 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 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 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 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 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; }
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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum SliceSource {
219 Bus,
220 Dirs,
221 Union,
222}
223
224#[derive(Debug, Clone)]
226pub struct UnionOutcome {
227 pub set: SliceSet,
228 pub from_bus: Vec<String>,
230 pub dirs_only: Vec<String>,
232 pub disagreements: Vec<SliceDisagreement>,
233}
234
235impl SliceSet {
236 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 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 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 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 (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 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 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 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 assert!(
424 SliceSet::read_cache(Path::new("/nonexistent-zkf"))
425 .slices()
426 .is_empty()
427 );
428 }
429
430 #[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 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 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 #[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 #[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 #[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}