use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubjectDecl {
pub path: String,
pub class: String,
pub type_name: String,
pub common: Option<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 fanout: Option<String>,
pub idempotent: Option<bool>,
pub since: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobDecl {
pub tier: String,
pub endpoints: Vec<String>,
pub algo: Option<String>,
pub reference: Option<String>,
pub encoding: Option<String>,
pub since: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MediaDecl {
pub path: String,
pub encoding: String,
pub attachment: Option<String>,
pub cardinality: Option<i64>,
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 blob: Vec<BlobDecl>,
pub media: Vec<MediaDecl>,
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)
}
pub fn serves_blob_tier(&self, tier: &str) -> bool {
self.blob.iter().any(|b| b.tier == tier)
}
}
#[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(),
common: s(e.get("common")),
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")),
fanout: s(e.get("fanout")),
idempotent: e.get("idempotent").and_then(|v| v.as_bool()),
encoding: s(e.get("encoding")),
since: s(e.get("since")),
description: s(e.get("description")),
});
}
let mut blob = Vec::new();
for e in array("blob") {
blob.push(BlobDecl {
tier: s(e.get("tier")).ok_or_else(|| err("[[blob]] missing tier"))?,
endpoints: e
.get("endpoints")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
algo: s(e.get("algo")),
reference: s(e.get("reference")),
encoding: s(e.get("encoding")),
since: s(e.get("since")),
description: s(e.get("description")),
});
}
let mut media = Vec::new();
for e in array("media") {
media.push(MediaDecl {
path: s(e.get("path")).ok_or_else(|| err("[[media]] missing path"))?,
encoding: s(e.get("encoding")).ok_or_else(|| err("[[media]] missing encoding"))?,
attachment: s(e.get("attachment")),
cardinality: e.get("cardinality").and_then(|v| v.as_integer()),
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,
blob,
media,
deprecated,
})
}
pub fn to_toml(slice: &RegistrySlice) -> String {
fn s(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out.push('"');
out
}
fn opt(out: &mut String, key: &str, value: Option<&str>) {
if let Some(v) = value {
out.push_str(&format!("{key} = {}\n", s(v)));
}
}
fn opt_int(out: &mut String, key: &str, value: Option<i64>) {
if let Some(v) = value {
out.push_str(&format!("{key} = {v}\n"));
}
}
let mut out = String::new();
out.push_str("[registry]\n");
out.push_str(&format!("version = {}\n", s(&slice.version)));
out.push_str(&format!("app = {}\n", s(&slice.app)));
out.push_str(&format!("convention = {}\n", slice.convention));
match &slice.service_origin {
Some(origin) => {
out.push_str("\n[service]\n");
out.push_str(&format!("name = {}\n", s(&slice.name)));
out.push_str(&format!("origin = {}\n", s(origin)));
}
None => {
out.push_str("\n[producer]\n");
out.push_str(&format!("name = {}\n", s(&slice.name)));
}
}
opt(&mut out, "description", slice.description.as_deref());
for d in &slice.subjects {
out.push_str("\n[[subject]]\n");
out.push_str(&format!("path = {}\n", s(&d.path)));
out.push_str(&format!("class = {}\n", s(&d.class)));
if !d.type_name.is_empty() {
out.push_str(&format!("type = {}\n", s(&d.type_name)));
}
opt(&mut out, "common", d.common.as_deref());
opt(&mut out, "qos", d.qos.as_deref());
opt_int(&mut out, "ttl_s", d.ttl_s);
opt(&mut out, "unit", d.unit.as_deref());
opt(&mut out, "rate", d.rate.as_deref());
opt_int(&mut out, "cardinality", d.cardinality);
opt(&mut out, "encoding", d.encoding.as_deref());
opt(&mut out, "since", d.since.as_deref());
opt(&mut out, "description", d.description.as_deref());
}
for d in &slice.procedures {
out.push_str("\n[[procedure]]\n");
out.push_str(&format!("path = {}\n", s(&d.path)));
if !d.kind.is_empty() {
out.push_str(&format!("kind = {}\n", s(&d.kind)));
}
opt(&mut out, "request", d.request.as_deref());
opt(&mut out, "reply", d.reply.as_deref());
opt(&mut out, "encoding", d.encoding.as_deref());
opt(&mut out, "fanout", d.fanout.as_deref());
if let Some(i) = d.idempotent {
out.push_str(&format!("idempotent = {i}\n"));
}
opt(&mut out, "since", d.since.as_deref());
opt(&mut out, "description", d.description.as_deref());
}
for d in &slice.blob {
out.push_str("\n[[blob]]\n");
out.push_str(&format!("tier = {}\n", s(&d.tier)));
if !d.endpoints.is_empty() {
let items: Vec<String> = d.endpoints.iter().map(|e| s(e)).collect();
out.push_str(&format!("endpoints = [{}]\n", items.join(", ")));
}
opt(&mut out, "algo", d.algo.as_deref());
opt(&mut out, "reference", d.reference.as_deref());
opt(&mut out, "encoding", d.encoding.as_deref());
opt(&mut out, "since", d.since.as_deref());
opt(&mut out, "description", d.description.as_deref());
}
for d in &slice.media {
out.push_str("\n[[media]]\n");
out.push_str(&format!("path = {}\n", s(&d.path)));
out.push_str(&format!("encoding = {}\n", s(&d.encoding)));
opt(&mut out, "attachment", d.attachment.as_deref());
if let Some(c) = d.cardinality {
out.push_str(&format!("cardinality = {c}\n"));
}
opt(&mut out, "since", d.since.as_deref());
opt(&mut out, "description", d.description.as_deref());
}
for d in &slice.deprecated {
out.push_str("\n[[deprecated]]\n");
out.push_str(&format!("path = {}\n", s(&d.path)));
opt(&mut out, "since", d.since.as_deref());
opt(&mut out, "replaced_by", d.replaced_by.as_deref());
}
out
}
#[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,
},
UnknownBlobTier {
tier: String,
},
MissingBlobTier {
tier: 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::UnknownBlobTier { tier } => format!("serves unknown @blob tier {tier}"),
Self::MissingBlobTier { tier } => format!("does not serve @blob tier {tier}"),
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 b in &served.blob {
if !local.serves_blob_tier(&b.tier) {
out.push(SliceFinding::UnknownBlobTier {
tier: b.tier.clone(),
});
}
}
for b in &local.blob {
if !served.serves_blob_tier(&b.tier) {
out.push(SliceFinding::MissingBlobTier {
tier: b.tier.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 toml_export_round_trips_every_carried_field() {
let source = r#"
[registry]
version = "2.1"
app = "acme"
convention = 1
[producer]
name = "netring"
description = "flow capture"
[[subject]]
path = "flows/{proto}/count"
class = "telemetry"
type = "TelemetryPoint"
qos = "sampled"
unit = "packets"
cardinality = 512
encoding = "application/cbor"
since = "1.0"
description = "per-protocol flow counter"
[[subject]]
path = "health"
class = "state"
type = "Health"
common = "health"
ttl_s = 60
rate = "burst"
[[procedure]]
path = "capture/trigger"
kind = "write"
request = "CaptureSpec"
reply = "Ack"
encoding = "application/json"
fanout = "forbidden"
idempotent = false
since = "1.1"
description = "start a capture"
[[blob]]
tier = "artifact"
endpoints = ["manifest", "slice", "have"]
reference = "ArtifactRef"
encoding = "application/octet-stream"
since = "1.2"
description = "captured pcaps"
[[blob]]
tier = "store"
algo = "blake3"
since = "1.2"
[[media]]
path = "{stream}/preview/jpeg"
encoding = "image/jpeg"
attachment = "FrameMeta"
cardinality = 16
since = "1.3"
description = "preview rung"
[[deprecated]]
path = "flows/legacy"
since = "2.0"
replaced_by = "flows/{proto}/count"
"#;
let parsed = parse_slice(source).unwrap();
let emitted = to_toml(&parsed);
let back = parse_slice(&emitted)
.unwrap_or_else(|e| panic!("exported TOML must re-parse: {e}\n---\n{emitted}"));
assert_eq!(back, parsed, "exported TOML:\n{emitted}");
}
#[test]
fn toml_export_keeps_a_service_origin() {
let parsed = parse_slice(
r#"
[registry]
version = "1.0"
app = "acme"
convention = 1
[service]
name = "catalog"
origin = "@catalog"
"#,
)
.unwrap();
let emitted = to_toml(&parsed);
assert!(emitted.contains("[service]"), "{emitted}");
assert_eq!(parse_slice(&emitted).unwrap(), parsed);
}
#[test]
fn toml_export_escapes_free_text() {
let mut parsed = parse_slice(
r#"
[registry]
version = "1.0"
app = "acme"
convention = 1
[producer]
name = "p"
"#,
)
.unwrap();
parsed.description = Some("a \"quoted\" \\ back\nslash".into());
let emitted = to_toml(&parsed);
assert_eq!(parse_slice(&emitted).unwrap(), parsed, "{emitted}");
}
#[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 blob_entries_parse_lax_with_only_tier_required() {
let header = r#"
[registry]
version = "1.8"
app = "acme"
convention = 1
[producer]
name = "netring"
"#;
let slice = parse_slice(&format!(
r#"{header}
[[blob]]
tier = "artifact"
endpoints = ["manifest", "have"]
reference = "Delivery"
[[blob]]
tier = "flux"
"#
))
.unwrap();
assert!(slice.serves_blob_tier("artifact"));
let decl = slice.blob.iter().find(|b| b.tier == "artifact").unwrap();
assert_eq!(decl.endpoints, ["manifest", "have"]);
assert_eq!(decl.reference.as_deref(), Some("Delivery"));
assert_eq!(decl.algo, None);
assert!(slice.serves_blob_tier("flux"));
assert!(parse_slice(&format!("{header}\n[[blob]]\nalgo = \"blake3\"\n")).is_err());
let old = parse_slice(header).unwrap();
assert!(old.blob.is_empty());
assert!(!old.serves_blob_tier("artifact"));
}
#[test]
fn blob_tier_drift_is_a_finding() {
let with = |tiers: &[&str]| {
let mut src = String::from(
"[registry]\nversion = \"1.8\"\napp = \"acme\"\nconvention = 1\n\
[producer]\nname = \"netring\"\n",
);
for t in tiers {
src.push_str(&format!("[[blob]]\ntier = {t:?}\n"));
}
parse_slice(&src).unwrap()
};
let served = with(&["artifact", "tree"]);
let local = with(&["tree", "store"]);
let findings = diff(&served, &local);
assert!(
findings
.iter()
.any(|f| matches!(f, SliceFinding::UnknownBlobTier { tier } if tier == "artifact"))
);
assert!(
findings
.iter()
.any(|f| matches!(f, SliceFinding::MissingBlobTier { tier } if tier == "store"))
);
assert!(diff(&served, &served).is_empty());
}
#[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"));
}
}