use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubjectDecl {
pub path: String,
pub class: String,
pub type_name: String,
pub since: Option<String>,
pub description: Option<String>,
pub qos: Option<String>,
pub ttl_s: Option<i64>,
pub unit: Option<String>,
pub rate: Option<String>,
pub cardinality: Option<i64>,
pub encoding: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProcedureDecl {
pub path: String,
pub kind: String,
pub reply: Option<String>,
pub request: Option<String>,
pub encoding: Option<String>,
pub since: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeprecationDecl {
pub path: String,
pub since: Option<String>,
pub replaced_by: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistrySlice {
pub version: String,
pub app: String,
pub convention: i64,
pub name: String,
pub service_origin: Option<String>,
pub description: Option<String>,
pub subjects: Vec<SubjectDecl>,
pub procedures: Vec<ProcedureDecl>,
pub deprecated: Vec<DeprecationDecl>,
}
impl RegistrySlice {
pub fn subjects_in(&self, class: &str) -> impl Iterator<Item = &SubjectDecl> {
self.subjects.iter().filter(move |s| s.class == class)
}
pub fn serves_subject(&self, path: &str) -> bool {
self.subjects.iter().any(|s| s.path == path)
}
pub fn serves_procedure(&self, path: &str) -> bool {
self.procedures.iter().any(|p| p.path == path)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SliceError(String);
impl fmt::Display for SliceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "malformed registry slice: {}", self.0)
}
}
impl std::error::Error for SliceError {}
pub fn parse_slice(toml_src: &str) -> Result<RegistrySlice, SliceError> {
let doc: toml::Value = toml::from_str(toml_src).map_err(|e| SliceError(e.to_string()))?;
let err = |m: &str| SliceError(m.to_string());
let s = |v: Option<&toml::Value>| v.and_then(|v| v.as_str()).map(str::to_string);
let header = doc
.get("registry")
.ok_or_else(|| err("missing [registry]"))?;
let version = s(header.get("version")).ok_or_else(|| err("[registry] missing version"))?;
let app = s(header.get("app")).ok_or_else(|| err("[registry] missing app"))?;
let convention = header
.get("convention")
.and_then(|v| v.as_integer())
.ok_or_else(|| err("[registry] missing convention"))?;
let (name, service_origin, description) = if let Some(svc) = doc.get("service") {
(
s(svc.get("name")).ok_or_else(|| err("[service] missing name"))?,
Some(s(svc.get("origin")).ok_or_else(|| err("[service] missing origin"))?),
s(svc.get("description")),
)
} else if let Some(prod) = doc.get("producer") {
(
s(prod.get("name")).ok_or_else(|| err("[producer] missing name"))?,
None,
s(prod.get("description")),
)
} else {
return Err(err("missing [producer] or [service]"));
};
let array = |key: &str| -> Vec<&toml::Value> {
doc.get(key)
.and_then(|v| v.as_array())
.map(|a| a.iter().collect())
.unwrap_or_default()
};
let mut subjects = Vec::new();
for e in array("subject") {
subjects.push(SubjectDecl {
path: s(e.get("path")).ok_or_else(|| err("[[subject]] missing path"))?,
class: s(e.get("class")).ok_or_else(|| err("[[subject]] missing class"))?,
type_name: s(e.get("type")).unwrap_or_default(),
since: s(e.get("since")),
description: s(e.get("description")),
qos: s(e.get("qos")),
ttl_s: e.get("ttl_s").and_then(|v| v.as_integer()),
unit: s(e.get("unit")),
rate: s(e.get("rate")),
cardinality: e.get("cardinality").and_then(|v| v.as_integer()),
encoding: s(e.get("encoding")),
});
}
let mut procedures = Vec::new();
for e in array("procedure") {
procedures.push(ProcedureDecl {
path: s(e.get("path")).ok_or_else(|| err("[[procedure]] missing path"))?,
kind: s(e.get("kind")).unwrap_or_default(),
reply: s(e.get("reply")),
request: s(e.get("request")),
encoding: s(e.get("encoding")),
since: s(e.get("since")),
description: s(e.get("description")),
});
}
let mut deprecated = Vec::new();
for e in array("deprecated") {
deprecated.push(DeprecationDecl {
path: s(e.get("path")).ok_or_else(|| err("[[deprecated]] missing path"))?,
since: s(e.get("since")),
replaced_by: s(e.get("replaced_by")),
});
}
Ok(RegistrySlice {
version,
app,
convention,
name,
service_origin,
description,
subjects,
procedures,
deprecated,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SliceFinding {
VersionSkew {
served: String,
local: String,
},
UnknownSubject {
path: String,
class: String,
},
MissingSubject {
path: String,
class: String,
},
UnknownProcedure {
path: String,
},
MissingProcedure {
path: String,
},
ServesDeprecated {
path: String,
replaced_by: Option<String>,
},
}
impl SliceFinding {
pub fn summary(&self) -> String {
match self {
Self::VersionSkew { served, local } => {
format!("registry {served} (we compiled {local})")
}
Self::UnknownSubject { path, class } => format!("serves unknown {class} {path}"),
Self::MissingSubject { path, class } => format!("does not serve {class} {path}"),
Self::UnknownProcedure { path } => format!("serves unknown procedure {path}"),
Self::MissingProcedure { path } => format!("does not serve procedure {path}"),
Self::ServesDeprecated { path, replaced_by } => match replaced_by {
Some(r) => format!("serves deprecated {path} (use {r})"),
None => format!("serves deprecated {path}"),
},
}
}
}
pub fn diff(served: &RegistrySlice, local: &RegistrySlice) -> Vec<SliceFinding> {
let mut out = Vec::new();
if served.version != local.version {
out.push(SliceFinding::VersionSkew {
served: served.version.clone(),
local: local.version.clone(),
});
}
for s in &served.subjects {
if !local.serves_subject(&s.path) {
out.push(SliceFinding::UnknownSubject {
path: s.path.clone(),
class: s.class.clone(),
});
}
}
for s in &local.subjects {
if !served.serves_subject(&s.path) {
out.push(SliceFinding::MissingSubject {
path: s.path.clone(),
class: s.class.clone(),
});
}
}
for p in &served.procedures {
if !local.serves_procedure(&p.path) {
out.push(SliceFinding::UnknownProcedure {
path: p.path.clone(),
});
}
}
for p in &local.procedures {
if !served.serves_procedure(&p.path) {
out.push(SliceFinding::MissingProcedure {
path: p.path.clone(),
});
}
}
for d in &served.deprecated {
if served.serves_subject(&d.path) || served.serves_procedure(&d.path) {
out.push(SliceFinding::ServesDeprecated {
path: d.path.clone(),
replaced_by: d.replaced_by.clone(),
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_service_slice_carries_its_origin() {
let slice = parse_slice(
r#"
[registry]
version = "1.0"
app = "acme"
convention = 1
[service]
name = "catalog"
origin = "@catalog"
[[subject]]
path = "entity/{entity_id}"
class = "state"
type = "Entity"
[[procedure]]
path = "introspect"
kind = "read"
"#,
)
.unwrap();
assert_eq!(slice.service_origin.as_deref(), Some("@catalog"));
assert!(slice.serves_procedure("introspect"));
}
#[test]
fn a_slice_identical_to_ours_is_no_finding() {
let slice = parse_slice(
r#"
[registry]
version = "1.0"
app = "acme"
convention = 1
[producer]
name = "sysinfo"
[[subject]]
path = "cpu/usage"
class = "telemetry"
type = "TelemetryPoint"
[[procedure]]
path = "introspect"
kind = "read"
"#,
)
.unwrap();
assert!(diff(&slice, &slice).is_empty());
}
#[test]
fn skew_and_drift_are_findings() {
let local = parse_slice(
r#"
[registry]
version = "1.1"
app = "zensight"
convention = 1
[producer]
name = "sysinfo"
[[subject]]
path = "cpu/usage"
class = "telemetry"
type = "TelemetryPoint"
[[procedure]]
path = "introspect"
kind = "read"
"#,
)
.unwrap();
let served = parse_slice(
r#"
[registry]
version = "1.2"
app = "zensight"
convention = 1
[producer]
name = "sysinfo"
[[subject]]
path = "cpu/temperature"
class = "telemetry"
type = "TelemetryPoint"
[[procedure]]
path = "introspect"
kind = "read"
"#,
)
.unwrap();
let findings = diff(&served, &local);
assert!(findings.iter().any(|f| matches!(
f,
SliceFinding::VersionSkew { served, local } if served == "1.2" && local == "1.1"
)));
assert!(findings.iter().any(
|f| matches!(f, SliceFinding::UnknownSubject { path, .. } if path == "cpu/temperature")
));
assert!(findings.iter().any(
|f| matches!(f, SliceFinding::MissingSubject { path, .. } if path == "cpu/usage")
));
}
#[test]
fn unknown_fields_do_not_break_the_parse() {
let slice = parse_slice(
r#"
[registry]
version = "9.9"
app = "zensight"
convention = 1
future_knob = true
[producer]
name = "sysinfo"
[[subject]]
path = "cpu/usage"
class = "telemetry"
type = "TelemetryPoint"
unheard_of = "whatever"
"#,
)
.unwrap();
assert_eq!(slice.version, "9.9");
assert!(slice.serves_subject("cpu/usage"));
}
#[test]
fn a_slice_without_a_version_cannot_be_diffed_and_is_rejected() {
let e = parse_slice(
r#"
[registry]
app = "zensight"
convention = 1
[producer]
name = "sysinfo"
"#,
)
.unwrap_err();
assert!(e.to_string().contains("version"));
}
}