use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Result, anyhow};
use zenkey::{RegistrySlice, parse_slice};
use zenoh::Session;
#[derive(Debug, Clone, Default)]
struct ParsedSubjects {
idx: Vec<usize>,
pats: Vec<zenkey::pattern::SubjectPattern>,
}
#[derive(Debug, Clone, Default)]
pub struct SliceSet {
slices: Vec<RegistrySlice>,
raw: Vec<String>,
parsed: Vec<std::collections::BTreeMap<String, ParsedSubjects>>,
}
fn parse_subjects(slice: &RegistrySlice) -> std::collections::BTreeMap<String, ParsedSubjects> {
let mut out: std::collections::BTreeMap<String, ParsedSubjects> = Default::default();
for (i, s) in slice.subjects.iter().enumerate() {
if let Ok(p) = zenkey::pattern::SubjectPattern::parse(&s.path) {
let entry = out.entry(s.class.clone()).or_default();
entry.idx.push(i);
entry.pats.push(p);
}
}
out
}
impl SliceSet {
pub fn from_dirs(dirs: &[PathBuf]) -> Result<SliceSet> {
let mut set = SliceSet::default();
for dir in dirs {
let mut paths: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| anyhow!("--registry {}: {e}", dir.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
.filter(|p| p.file_name().is_none_or(|n| n != "types.toml"))
.collect();
paths.sort();
for path in paths {
let text = std::fs::read_to_string(&path)
.map_err(|e| anyhow!("{}: {e}", path.display()))?;
let slice = parse_slice(&text).map_err(|e| {
anyhow!(
"{}: does not parse as a registry slice: {e}",
path.display()
)
})?;
set.push(slice, text);
}
}
Ok(set)
}
pub async fn from_bus(session: &Session, base: &str, timeout: Duration) -> Result<SliceSet> {
let pairs = crate::query::fleet_registry_raw(session, base, timeout).await?;
let mut set = SliceSet::default();
for (slice, raw) in pairs {
set.push(slice, raw);
}
Ok(set)
}
fn push(&mut self, slice: RegistrySlice, raw: String) {
let parsed = parse_subjects(&slice);
if let Some(i) = self.slices.iter().position(|s| s.name == slice.name) {
self.slices[i] = slice;
self.raw[i] = raw;
self.parsed[i] = parsed;
} else {
self.slices.push(slice);
self.raw.push(raw);
self.parsed.push(parsed);
}
}
pub fn entries(&self) -> impl Iterator<Item = (&RegistrySlice, &str)> {
self.slices.iter().zip(self.raw.iter().map(String::as_str))
}
pub fn slices(&self) -> &[RegistrySlice] {
&self.slices
}
pub fn get(&self, name: &str) -> Option<&RegistrySlice> {
self.slices.iter().find(|s| s.name == name)
}
pub fn by_service_origin(&self, origin: &str) -> Option<&RegistrySlice> {
self.slices
.iter()
.find(|s| s.service_origin.as_deref() == Some(origin))
}
pub fn refine<'s>(
&'s self,
producer: &str,
class: &str,
tail: &[&str],
) -> Option<(&'s zenkey::slice::SubjectDecl, Vec<(String, String)>)> {
let i = self.slices.iter().position(|s| s.name == producer)?;
let slice = &self.slices[i];
let candidates = self.parsed[i].get(class)?;
let (winner, binds) = zenkey::pattern::best_match(&candidates.pats, tail)?;
let subject_idx = candidates.idx[winner];
Some((
&slice.subjects[subject_idx],
binds.into_iter().map(|(n, v)| (n.to_string(), v)).collect(),
))
}
pub fn from_slices(slices: Vec<RegistrySlice>) -> SliceSet {
let raw = vec![String::new(); slices.len()];
let parsed = slices.iter().map(parse_subjects).collect();
SliceSet {
slices,
raw,
parsed,
}
}
pub fn write_cache(&self, dir: &Path) -> Result<()> {
std::fs::create_dir_all(dir)?;
for (slice, raw) in self.slices.iter().zip(&self.raw) {
if raw.is_empty() {
continue; }
std::fs::write(dir.join(format!("{}.toml", slice.name)), raw)?;
}
Ok(())
}
pub fn read_cache(dir: &Path) -> SliceSet {
if !dir.is_dir() {
return SliceSet::default();
}
SliceSet::from_dirs(&[dir.to_path_buf()]).unwrap_or_default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SliceSource {
Bus,
Dirs,
Union,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct SliceDisagreement {
pub producer: String,
pub bus_version: String,
pub dirs_version: String,
pub shape_differs: bool,
}
#[derive(Debug, Clone)]
pub struct UnionOutcome {
pub set: SliceSet,
pub from_bus: Vec<String>,
pub dirs_only: Vec<String>,
pub disagreements: Vec<SliceDisagreement>,
}
impl SliceSet {
pub async fn from_union(
session: &zenoh::Session,
base: &str,
dirs: &[std::path::PathBuf],
timeout: std::time::Duration,
) -> Result<UnionOutcome> {
let bus = SliceSet::from_bus(session, base, timeout)
.await
.unwrap_or_default();
let disk = if dirs.is_empty() {
SliceSet::default()
} else {
SliceSet::from_dirs(dirs)?
};
let mut merged = SliceSet::default();
let mut from_bus = Vec::new();
let mut dirs_only = Vec::new();
let mut disagreements = Vec::new();
for (served, raw) in bus.entries() {
from_bus.push(served.name.clone());
if let Some(local) = disk.get(&served.name)
&& (local.version != served.version || local != served)
{
disagreements.push(SliceDisagreement {
producer: served.name.clone(),
bus_version: served.version.clone(),
dirs_version: local.version.clone(),
shape_differs: {
let mut a = served.clone();
let mut b = local.clone();
a.version = String::new();
b.version = String::new();
a != b
},
});
}
merged.push(served.clone(), raw.to_string());
}
for (local, raw) in disk.entries() {
if bus.get(&local.name).is_none() {
dirs_only.push(local.name.clone());
merged.push(local.clone(), raw.to_string());
}
}
Ok(UnionOutcome {
set: merged,
from_bus,
dirs_only,
disagreements,
})
}
}
#[cfg(test)]
impl SliceSet {
pub(crate) fn from_toml_for_tests(toml: &str) -> SliceSet {
let mut set = SliceSet::default();
set.push(parse_slice(toml).unwrap(), toml.to_string());
set
}
}
#[cfg(test)]
mod tests {
use super::*;
const A: &str = r#"
[registry]
version = "1.0"
app = "t"
convention = 1
[producer]
name = "alpha"
[[subject]]
path = "flow/{q}"
class = "telemetry"
type = "Point"
[[subject]]
path = "flow/special"
class = "telemetry"
type = "Special"
"#;
#[test]
fn refine_uses_shared_precedence() {
let mut set = SliceSet::default();
set.push(parse_slice(A).unwrap(), A.to_string());
let (s, binds) = set
.refine("alpha", "telemetry", &["flow", "special"])
.unwrap();
assert_eq!(s.type_name, "Special");
assert!(binds.is_empty());
let (s, binds) = set.refine("alpha", "telemetry", &["flow", "p95"]).unwrap();
assert_eq!(s.type_name, "Point");
assert_eq!(binds, vec![("q".to_string(), "p95".to_string())]);
assert!(set.refine("alpha", "state", &["flow", "p95"]).is_none());
}
#[test]
fn cache_round_trips_and_last_slice_wins() {
let mut set = SliceSet::default();
set.push(parse_slice(A).unwrap(), A.to_string());
set.push(parse_slice(A).unwrap(), A.to_string());
assert_eq!(set.slices().len(), 1);
let dir = std::env::temp_dir().join(format!("zenkey-fleet-cache-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
set.write_cache(&dir).unwrap();
let back = SliceSet::read_cache(&dir);
assert_eq!(back.slices().len(), 1);
assert_eq!(back.get("alpha").unwrap().subjects.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
assert!(
SliceSet::read_cache(Path::new("/nonexistent-zkf"))
.slices()
.is_empty()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn union_degrades_to_dirs_when_the_bus_is_silent() {
let session = crate::session::open(&[], &[], false).await.unwrap();
let dir =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixture-tests/registry");
let out = SliceSet::from_union(&session, "", &[dir], std::time::Duration::from_millis(200))
.await
.unwrap();
assert!(out.from_bus.is_empty(), "no bus answered");
assert!(!out.dirs_only.is_empty(), "dirs supplied the slices");
assert!(out.disagreements.is_empty());
assert_eq!(out.set.slices().len(), out.dirs_only.len());
}
}