use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const MEERKAT_METADATA_PREFIX: &str = "meerkat.";
pub const RESERVED_MOB_LABEL_KEYS: [&str; 3] = ["mob_id", "role", "meerkat_id"];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub struct SurfaceMetadata {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_context: Option<serde_json::Value>,
}
impl SurfaceMetadata {
#[must_use]
pub fn from_optional_parts(
labels: Option<BTreeMap<String, String>>,
app_context: Option<serde_json::Value>,
) -> Self {
Self {
labels: labels.unwrap_or_default(),
app_context,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.labels.is_empty() && self.app_context.is_none()
}
pub fn validate_public(&self) -> Result<(), SurfaceMetadataError> {
validate_public_labels(Some(&self.labels))?;
validate_public_app_context(self.app_context.as_ref())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub struct RuntimeMetadata {
#[serde(default, skip_serializing_if = "SurfaceMetadata::is_empty")]
pub surface: SurfaceMetadata,
}
impl RuntimeMetadata {
#[must_use]
pub fn from_surface(surface: SurfaceMetadata) -> Self {
Self { surface }
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.surface.is_empty()
}
}
impl From<SurfaceMetadata> for RuntimeMetadata {
fn from(surface: SurfaceMetadata) -> Self {
Self::from_surface(surface)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SurfaceMetadataError {
#[error("metadata label key '{key}' is reserved for Meerkat-owned runtime facts")]
ReservedLabelKey { key: String },
#[error("app_context key '{key}' is reserved for Meerkat-owned runtime facts")]
ReservedAppContextKey { key: String },
}
#[must_use]
pub fn is_reserved_meerkat_metadata_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
key == "meerkat" || key.starts_with(MEERKAT_METADATA_PREFIX)
}
#[must_use]
pub fn is_reserved_meerkat_label_key(key: &str) -> bool {
let normalized = key.to_ascii_lowercase();
RESERVED_MOB_LABEL_KEYS.contains(&normalized.as_str())
|| is_reserved_meerkat_metadata_key(&normalized)
}
pub fn validate_public_labels(
labels: Option<&BTreeMap<String, String>>,
) -> Result<(), SurfaceMetadataError> {
let Some(labels) = labels else {
return Ok(());
};
for key in labels.keys() {
if is_reserved_meerkat_label_key(key) {
return Err(SurfaceMetadataError::ReservedLabelKey { key: key.clone() });
}
}
Ok(())
}
pub fn validate_public_app_context(
app_context: Option<&serde_json::Value>,
) -> Result<(), SurfaceMetadataError> {
let Some(serde_json::Value::Object(map)) = app_context else {
return Ok(());
};
for key in map.keys() {
if is_reserved_meerkat_metadata_key(key) {
return Err(SurfaceMetadataError::ReservedAppContextKey { key: key.clone() });
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn surface_metadata_omits_empty_fields() {
let encoded = serde_json::to_value(SurfaceMetadata::default()).unwrap();
assert_eq!(encoded, json!({}));
}
#[test]
fn surface_metadata_round_trips_existing_labels_and_app_context_shape() {
let metadata = SurfaceMetadata::from_optional_parts(
Some(BTreeMap::from([(
"client.thread_id".into(),
"thread-1".into(),
)])),
Some(json!({"client_ref": {"view": "compact"}})),
);
let encoded = serde_json::to_value(&metadata).unwrap();
assert_eq!(
encoded,
json!({
"labels": { "client.thread_id": "thread-1" },
"app_context": { "client_ref": { "view": "compact" } }
})
);
assert_eq!(
serde_json::from_value::<SurfaceMetadata>(encoded).unwrap(),
metadata
);
}
#[test]
fn public_validation_rejects_meerkat_owned_label_keys() {
for key in [
"mob_id",
"role",
"meerkat_id",
"meerkat.runtime_id",
"Meerkat.Runtime_Id",
"ROLE",
] {
let metadata = SurfaceMetadata::from_optional_parts(
Some(BTreeMap::from([(key.to_string(), "spoof".to_string())])),
None,
);
assert!(matches!(
metadata.validate_public(),
Err(SurfaceMetadataError::ReservedLabelKey { .. })
));
}
}
#[test]
fn public_validation_rejects_meerkat_owned_app_context_keys() {
let metadata = SurfaceMetadata::from_optional_parts(
None,
Some(json!({
"Meerkat.Runtime_Id": "spoof",
"client_ref": "ok"
})),
);
assert!(matches!(
metadata.validate_public(),
Err(SurfaceMetadataError::ReservedAppContextKey { .. })
));
}
}