use std::fmt;
use crate::common_state::CommonFamily;
use crate::encoding::WireEncoding;
use crate::grammar::{BlobTier, Class};
use crate::origin::ServiceOrigin;
use crate::qos::QosProfile;
pub trait SliceToken: Sized {
fn from_token(token: &str) -> Option<Self>;
fn token(&self) -> &str;
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Declared<T> {
Known(T),
Other(String),
}
impl<T: SliceToken> Declared<T> {
pub fn parse(token: &str) -> Self {
match T::from_token(token) {
Some(known) => Declared::Known(known),
None => Declared::Other(token.to_string()),
}
}
pub fn token(&self) -> &str {
match self {
Declared::Known(k) => k.token(),
Declared::Other(s) => s,
}
}
pub fn known(&self) -> Option<&T> {
match self {
Declared::Known(k) => Some(k),
Declared::Other(_) => None,
}
}
pub fn is(&self, other: &T) -> bool
where
T: PartialEq,
{
matches!(self, Declared::Known(k) if k == other)
}
}
impl<T: SliceToken> From<T> for Declared<T> {
fn from(value: T) -> Self {
Declared::Known(value)
}
}
impl<T: SliceToken> fmt::Display for Declared<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.token())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProcedureKind {
Read,
Write,
}
impl SliceToken for ProcedureKind {
fn from_token(token: &str) -> Option<Self> {
match token {
"read" => Some(ProcedureKind::Read),
"write" => Some(ProcedureKind::Write),
_ => None,
}
}
fn token(&self) -> &str {
match self {
ProcedureKind::Read => "read",
ProcedureKind::Write => "write",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Fanout {
Allowed,
Forbidden,
}
impl SliceToken for Fanout {
fn from_token(token: &str) -> Option<Self> {
match token {
"allowed" => Some(Fanout::Allowed),
"forbidden" => Some(Fanout::Forbidden),
_ => None,
}
}
fn token(&self) -> &str {
match self {
Fanout::Allowed => "allowed",
Fanout::Forbidden => "forbidden",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum RateClass {
Rare,
Low,
Burst(u64),
Other(String),
}
impl RateClass {
pub fn parse(token: &str) -> RateClass {
match token {
"rare" => RateClass::Rare,
"low" => RateClass::Low,
other => match other
.strip_prefix("burst(")
.and_then(|r| r.strip_suffix("/h)"))
.and_then(|n| n.parse().ok())
{
Some(n) => RateClass::Burst(n),
None => RateClass::Other(other.to_string()),
},
}
}
pub fn cap_per_hour(&self) -> Option<u64> {
match self {
RateClass::Rare => Some(1),
RateClass::Low => Some(60),
RateClass::Burst(n) => Some(*n),
RateClass::Other(_) => None,
}
}
pub fn token(&self) -> String {
match self {
RateClass::Rare => "rare".to_string(),
RateClass::Low => "low".to_string(),
RateClass::Burst(n) => format!("burst({n}/h)"),
RateClass::Other(s) => s.clone(),
}
}
}
impl fmt::Display for RateClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.token())
}
}
impl SliceToken for Class {
fn from_token(token: &str) -> Option<Self> {
Class::from_chunk(token)
}
fn token(&self) -> &str {
self.chunk()
}
}
impl SliceToken for QosProfile {
fn from_token(token: &str) -> Option<Self> {
QosProfile::from_name(token)
}
fn token(&self) -> &str {
self.name()
}
}
impl SliceToken for BlobTier {
fn from_token(token: &str) -> Option<Self> {
BlobTier::from_chunk(token)
}
fn token(&self) -> &str {
self.chunk()
}
}
impl SliceToken for CommonFamily {
fn from_token(token: &str) -> Option<Self> {
CommonFamily::ALL.into_iter().find(|f| f.token() == token)
}
fn token(&self) -> &str {
CommonFamily::token(*self)
}
}
impl SliceToken for ServiceOrigin {
fn from_token(token: &str) -> Option<Self> {
ServiceOrigin::new(token).ok()
}
fn token(&self) -> &str {
self.as_str()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SubjectDecl {
pub path: String,
pub class: Declared<Class>,
pub type_name: String,
pub common: Option<Declared<CommonFamily>>,
pub since: Option<String>,
pub description: Option<String>,
pub qos: Option<Declared<QosProfile>>,
pub ttl_s: Option<i64>,
pub unit: Option<String>,
pub rate: Option<RateClass>,
pub cardinality: Option<i64>,
pub encoding: Option<WireEncoding>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ProcedureDecl {
pub path: String,
pub kind: Option<Declared<ProcedureKind>>,
pub reply: Option<String>,
pub request: Option<String>,
pub encoding: Option<WireEncoding>,
pub fanout: Option<Declared<Fanout>>,
pub idempotent: Option<bool>,
pub cardinality: Option<i64>,
pub since: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BlobDecl {
pub tier: Declared<BlobTier>,
pub endpoints: Vec<String>,
pub algo: Option<String>,
pub reference: Option<String>,
pub encoding: Option<WireEncoding>,
pub since: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct MediaDecl {
pub path: String,
pub encoding: WireEncoding,
pub attachment: Option<String>,
pub cardinality: Option<i64>,
pub since: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct DeprecationDecl {
pub path: String,
pub kind: DeprecatedKind,
pub since: Option<String>,
pub replaced_by: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DeprecatedKind {
#[default]
Subject,
Procedure,
}
impl DeprecatedKind {
pub fn as_str(self) -> &'static str {
match self {
DeprecatedKind::Subject => "subject",
DeprecatedKind::Procedure => "procedure",
}
}
}
impl std::str::FromStr for DeprecatedKind {
type Err = ();
fn from_str(s: &str) -> Result<DeprecatedKind, ()> {
match s {
"subject" => Ok(DeprecatedKind::Subject),
"procedure" => Ok(DeprecatedKind::Procedure),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RegistrySlice {
pub version: String,
pub app: String,
pub convention: i64,
pub name: String,
pub service_origin: Option<Declared<ServiceOrigin>>,
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 SubjectDecl {
#[must_use]
pub fn new(path: impl Into<String>, class: impl Into<Declared<Class>>) -> Self {
SubjectDecl {
path: path.into(),
class: class.into(),
type_name: String::new(),
common: None,
since: None,
description: None,
qos: None,
ttl_s: None,
unit: None,
rate: None,
cardinality: None,
encoding: None,
}
}
}
impl ProcedureDecl {
#[must_use]
pub fn new(path: impl Into<String>) -> Self {
ProcedureDecl {
path: path.into(),
kind: None,
reply: None,
request: None,
encoding: None,
fanout: None,
idempotent: None,
cardinality: None,
since: None,
description: None,
}
}
}
impl BlobDecl {
#[must_use]
pub fn new(tier: impl Into<Declared<BlobTier>>) -> Self {
BlobDecl {
tier: tier.into(),
endpoints: Vec::new(),
algo: None,
reference: None,
encoding: None,
since: None,
description: None,
}
}
}
impl MediaDecl {
#[must_use]
pub fn new(path: impl Into<String>, encoding: WireEncoding) -> Self {
MediaDecl {
path: path.into(),
encoding,
attachment: None,
cardinality: None,
since: None,
description: None,
}
}
}
impl DeprecationDecl {
#[must_use]
pub fn new(path: impl Into<String>) -> Self {
DeprecationDecl::of(DeprecatedKind::Subject, path)
}
#[must_use]
pub fn of(kind: DeprecatedKind, path: impl Into<String>) -> Self {
DeprecationDecl {
path: path.into(),
kind,
since: None,
replaced_by: None,
}
}
}
impl RegistrySlice {
#[must_use]
pub fn new(
version: impl Into<String>,
app: impl Into<String>,
name: impl Into<String>,
) -> Self {
RegistrySlice {
version: version.into(),
app: app.into(),
convention: 1,
name: name.into(),
service_origin: None,
description: None,
subjects: Vec::new(),
procedures: Vec::new(),
blob: Vec::new(),
media: Vec::new(),
deprecated: Vec::new(),
}
}
pub fn subjects_in(
&self,
class: impl Into<Declared<Class>>,
) -> impl Iterator<Item = &SubjectDecl> {
let class = class.into();
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: impl Into<Declared<BlobTier>>) -> bool {
let tier = tier.into();
self.blob.iter().any(|b| b.tier == tier)
}
pub fn blob_tiers(&self) -> impl Iterator<Item = &Declared<BlobTier>> {
self.blob.iter().map(|b| &b.tier)
}
pub fn serves_media(&self, path: &str) -> bool {
self.media.iter().any(|m| m.path == path)
}
}
#[derive(Debug, thiserror::Error)]
pub enum SliceError {
#[error("malformed registry slice: {0}")]
Toml(#[from] toml::de::Error),
#[error("malformed registry slice: {0}")]
Shape(String),
}
pub fn parse_slice(toml_src: &str) -> Result<RegistrySlice, SliceError> {
let doc: toml::Value = toml::from_str(toml_src)?;
let err = |m: &str| SliceError::Shape(m.to_string());
let s = |v: Option<&toml::Value>| v.and_then(|v| v.as_str()).map(str::to_string);
fn tok<T: SliceToken>(v: Option<&toml::Value>) -> Option<Declared<T>> {
v.and_then(|v| v.as_str()).map(Declared::parse)
}
fn enc(v: Option<&toml::Value>) -> Option<WireEncoding> {
v.and_then(|v| v.as_str())
.map(WireEncoding::from_encoding_str)
}
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(tok(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: tok(e.get("class")).ok_or_else(|| err("[[subject]] missing class"))?,
type_name: s(e.get("type")).unwrap_or_default(),
common: tok(e.get("common")),
since: s(e.get("since")),
description: s(e.get("description")),
qos: tok(e.get("qos")),
ttl_s: e.get("ttl_s").and_then(|v| v.as_integer()),
unit: s(e.get("unit")),
rate: e.get("rate").and_then(|v| v.as_str()).map(RateClass::parse),
cardinality: e.get("cardinality").and_then(|v| v.as_integer()),
encoding: enc(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: tok(e.get("kind")),
reply: s(e.get("reply")),
request: s(e.get("request")),
fanout: tok(e.get("fanout")),
idempotent: e.get("idempotent").and_then(|v| v.as_bool()),
cardinality: e.get("cardinality").and_then(|v| v.as_integer()),
encoding: enc(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: tok(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: enc(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: enc(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") {
let path = s(e.get("path")).ok_or_else(|| err("[[deprecated]] missing path"))?;
let kind = match s(e.get("kind")) {
None => DeprecatedKind::Subject,
Some(k) => k.parse().map_err(|()| {
err(&format!(
"[[deprecated]] {path:?} has kind = {k:?} — it is `subject` \
(the default) or `procedure`"
))
})?,
};
deprecated.push(DeprecationDecl {
path,
kind,
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_tok<T: SliceToken>(out: &mut String, key: &str, value: Option<&Declared<T>>) {
if let Some(v) = value {
out.push_str(&format!("{key} = {}\n", s(v.token())));
}
}
fn opt_enc(out: &mut String, key: &str, value: Option<&WireEncoding>) {
if let Some(v) = value {
out.push_str(&format!("{key} = {}\n", s(v.as_encoding_str())));
}
}
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.token())));
}
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.token())));
if !d.type_name.is_empty() {
out.push_str(&format!("type = {}\n", s(&d.type_name)));
}
opt_tok(&mut out, "common", d.common.as_ref());
opt_tok(&mut out, "qos", d.qos.as_ref());
opt_int(&mut out, "ttl_s", d.ttl_s);
opt(&mut out, "unit", d.unit.as_deref());
opt(
&mut out,
"rate",
d.rate.as_ref().map(RateClass::token).as_deref(),
);
opt_int(&mut out, "cardinality", d.cardinality);
opt_enc(&mut out, "encoding", d.encoding.as_ref());
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)));
opt_tok(&mut out, "kind", d.kind.as_ref());
opt(&mut out, "request", d.request.as_deref());
opt(&mut out, "reply", d.reply.as_deref());
opt_enc(&mut out, "encoding", d.encoding.as_ref());
opt_tok(&mut out, "fanout", d.fanout.as_ref());
if let Some(i) = d.idempotent {
out.push_str(&format!("idempotent = {i}\n"));
}
opt_int(&mut out, "cardinality", d.cardinality);
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.token())));
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_enc(&mut out, "encoding", d.encoding.as_ref());
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.as_encoding_str())));
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)));
if d.kind != DeprecatedKind::Subject {
out.push_str(&format!("kind = {}\n", s(d.kind.as_str())));
}
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: Declared<Class>,
},
MissingSubject {
path: String,
class: Declared<Class>,
},
UnknownProcedure {
path: String,
},
MissingProcedure {
path: String,
},
UnknownBlobTier {
tier: Declared<BlobTier>,
},
MissingBlobTier {
tier: Declared<BlobTier>,
},
UnknownMediaStream {
path: String,
},
MissingMediaStream {
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::UnknownBlobTier { tier } => format!("serves unknown @blob tier {tier}"),
Self::MissingBlobTier { tier } => format!("does not serve @blob tier {tier}"),
Self::UnknownMediaStream { path } => format!("serves unknown @media stream {path}"),
Self::MissingMediaStream { path } => format!("does not serve @media stream {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 b in &served.blob {
if !local.serves_blob_tier(b.tier.clone()) {
out.push(SliceFinding::UnknownBlobTier {
tier: b.tier.clone(),
});
}
}
for b in &local.blob {
if !served.serves_blob_tier(b.tier.clone()) {
out.push(SliceFinding::MissingBlobTier {
tier: b.tier.clone(),
});
}
}
for m in &served.media {
if !local.serves_media(&m.path) {
out.push(SliceFinding::UnknownMediaStream {
path: m.path.clone(),
});
}
}
for m in &local.media {
if !served.serves_media(&m.path) {
out.push(SliceFinding::MissingMediaStream {
path: m.path.clone(),
});
}
}
for d in &served.deprecated {
let still_served = match d.kind {
DeprecatedKind::Subject => served.serves_subject(&d.path),
DeprecatedKind::Procedure => served.serves_procedure(&d.path),
};
if still_served {
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"
[[procedure]]
path = "capture/{port}/drain"
kind = "write"
reply = "Ack"
cardinality = 8
since = "1.2"
description = "drain one port's ring"
[[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"
[[deprecated]]
kind = "procedure"
path = "flows/reset"
since = "2.0"
"#;
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 a_deprecation_without_a_kind_is_a_subject_and_stays_unwritten() {
let source = r#"
[registry]
version = "1.0"
app = "t"
convention = 1
[producer]
name = "p"
[[deprecated]]
path = "old"
[[deprecated]]
kind = "procedure"
path = "reset"
"#;
let parsed = parse_slice(source).unwrap();
assert_eq!(parsed.deprecated[0].kind, DeprecatedKind::Subject);
assert_eq!(parsed.deprecated[1].kind, DeprecatedKind::Procedure);
let emitted = to_toml(&parsed);
assert!(
emitted.contains("[[deprecated]]\npath = \"old\"\n"),
"{emitted}"
);
assert!(
emitted.contains("path = \"reset\"\nkind = \"procedure\"\n"),
"{emitted}"
);
}
#[test]
fn a_deprecation_with_an_unknown_kind_is_refused() {
let source = r#"
[registry]
version = "1.0"
app = "t"
convention = 1
[producer]
name = "p"
[[deprecated]]
kind = "media"
path = "old"
"#;
let e = parse_slice(source).expect_err("kind is a closed vocabulary");
assert!(e.to_string().contains("`procedure`"), "{e}");
}
#[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_ref().map(Declared::token),
Some("@catalog")
);
assert!(slice.serves_procedure("introspect"));
}
#[test]
fn a_malformed_slice_keeps_the_toml_error_as_its_source() {
use std::error::Error as _;
let err = parse_slice("this is not = = toml").unwrap_err();
assert!(matches!(err, SliceError::Toml(_)), "{err:?}");
let source = err.source().expect("a toml error underneath");
assert!(
source.downcast_ref::<toml::de::Error>().is_some(),
"source was {source:?}"
);
let shape = parse_slice("[registry]\nversion = \"1.0\"\n").unwrap_err();
assert!(matches!(shape, SliceError::Shape(_)), "{shape:?}");
assert!(shape.source().is_none());
assert!(shape.to_string().contains("missing app"), "{shape}");
}
#[test]
fn a_declared_column_keeps_what_it_does_not_recognise() {
let src = r#"
[registry]
version = "1.0"
app = "acme"
convention = 1
[producer]
name = "netring"
[[subject]]
path = "a"
class = "telemetry"
qos = "sampled"
[[subject]]
path = "b"
class = "metrics"
qos = "urgent"
"#;
let slice = parse_slice(src).unwrap();
assert_eq!(slice.subjects[0].class, Declared::Known(Class::Telemetry));
assert_eq!(
slice.subjects[0].qos,
Some(Declared::Known(QosProfile::Sampled))
);
assert_eq!(slice.subjects[1].class, Declared::Other("metrics".into()));
assert_eq!(
slice.subjects[1].qos,
Some(Declared::Other("urgent".into()))
);
assert_eq!(slice.subjects[1].class.token(), "metrics");
assert_eq!(slice.subjects[1].class.known(), None);
assert_eq!(slice.subjects_in(Class::Telemetry).count(), 1);
assert_eq!(
slice
.subjects_in(Declared::Other("metrics".to_string()))
.count(),
1
);
assert_eq!(parse_slice(&to_toml(&slice)).unwrap(), slice);
}
#[test]
fn a_rate_class_keeps_its_burst_budget() {
assert_eq!(RateClass::parse("rare").cap_per_hour(), Some(1));
assert_eq!(RateClass::parse("low").cap_per_hour(), Some(60));
assert_eq!(RateClass::parse("burst(240/h)"), RateClass::Burst(240));
assert_eq!(RateClass::parse("burst(240/h)").cap_per_hour(), Some(240));
assert_eq!(RateClass::parse("burst(240)").cap_per_hour(), None);
let odd = RateClass::parse("whenever");
assert_eq!(odd, RateClass::Other("whenever".into()));
assert_eq!(odd.cap_per_hour(), None);
for token in ["rare", "low", "burst(240/h)", "whenever"] {
assert_eq!(RateClass::parse(token).token(), token);
}
}
#[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(BlobTier::Artifact));
let decl = slice
.blob
.iter()
.find(|b| b.tier.is(&BlobTier::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(Declared::Other("flux".into())));
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(BlobTier::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.is(&BlobTier::Artifact))
));
assert!(findings.iter().any(
|f| matches!(f, SliceFinding::MissingBlobTier { tier } if tier.is(&BlobTier::Store))
));
assert!(diff(&served, &served).is_empty());
}
#[test]
fn media_stream_drift_is_a_finding() {
let with = |paths: &[&str]| {
let mut src = String::from(
"[registry]\nversion = \"1.16\"\napp = \"acme\"\nconvention = 1\n\
[producer]\nname = \"netring\"\n",
);
for p in paths {
src.push_str(&format!(
"[[media]]\npath = {p:?}\nencoding = \"image/jpeg\"\n"
));
}
parse_slice(&src).unwrap()
};
let served = with(&["{stream}/preview/jpeg", "{stream}/live/h264"]);
let local = with(&["{stream}/live/h264", "{stream}/still/png"]);
let findings = diff(&served, &local);
assert!(findings.iter().any(|f| matches!(
f,
SliceFinding::UnknownMediaStream { path } if path == "{stream}/preview/jpeg"
)));
assert!(findings.iter().any(|f| matches!(
f,
SliceFinding::MissingMediaStream { path } if path == "{stream}/still/png"
)));
assert!(!findings.iter().any(|f| matches!(
f,
SliceFinding::UnknownMediaStream { path }
| SliceFinding::MissingMediaStream { path } if path == "{stream}/live/h264"
)));
assert!(diff(&served, &served).is_empty());
let old = with(&[]);
assert!(diff(&old, &old).is_empty());
assert!(
diff(&old, &local)
.iter()
.all(|f| matches!(f, SliceFinding::MissingMediaStream { .. }))
);
}
#[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"));
}
}