1use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12use anyhow::{Result, anyhow};
13use zenkey::{RegistrySlice, parse_slice};
14use zenoh::Session;
15
16#[derive(Debug, Clone, Default)]
24struct ParsedSubjects {
25 idx: Vec<usize>,
27 pats: Vec<zenkey::pattern::SubjectPattern>,
29}
30
31#[derive(Debug, Clone, Default)]
33pub struct SliceSet {
34 slices: Vec<RegistrySlice>,
35 raw: Vec<String>,
38 parsed: Vec<std::collections::BTreeMap<String, ParsedSubjects>>,
42}
43
44fn 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 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 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 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 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 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 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 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 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 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; }
181 std::fs::write(dir.join(format!("{}.toml", slice.name)), raw)?;
182 }
183 Ok(())
184 }
185
186 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum SliceSource {
200 Bus,
201 Dirs,
202 Union,
203}
204
205#[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 pub shape_differs: bool,
218}
219
220#[derive(Debug, Clone)]
222pub struct UnionOutcome {
223 pub set: SliceSet,
224 pub from_bus: Vec<String>,
226 pub dirs_only: Vec<String>,
228 pub disagreements: Vec<SliceDisagreement>,
229}
230
231impl SliceSet {
232 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 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 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 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 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 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 assert!(
363 SliceSet::read_cache(Path::new("/nonexistent-zkf"))
364 .slices()
365 .is_empty()
366 );
367 }
368
369 #[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}