use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::TreetopError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MetadataSource(String);
impl MetadataSource {
pub fn try_new(url: impl Into<String>) -> crate::error::Result<Self> {
let url = url.into();
url::Url::parse(&url).map_err(TreetopError::InvalidUrl)?;
Ok(Self(url))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl std::fmt::Display for MetadataSource {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl Serialize for MetadataSource {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
#[derive(Serialize)]
struct Endpoint<'a> {
url: &'a str,
}
Endpoint { url: &self.0 }.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for MetadataSource {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Endpoint {
url: String,
}
let url = Endpoint::deserialize(deserializer)?.url;
Self::try_new(url).map_err(serde::de::Error::custom)
}
}
impl TryFrom<String> for MetadataSource {
type Error = TreetopError;
fn try_from(url: String) -> Result<Self, Self::Error> {
Self::try_new(url)
}
}
impl TryFrom<&str> for MetadataSource {
type Error = TreetopError;
fn try_from(url: &str) -> Result<Self, Self::Error> {
Self::try_new(url)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Metadata {
pub timestamp: String,
pub sha256: String,
pub size: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<MetadataSource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_frequency: Option<u32>,
pub entries: usize,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PoliciesMetadata {
pub allow_upload: bool,
pub schema_validation_mode: String,
pub policies: Metadata,
pub labels: Metadata,
pub schema: Metadata,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct RequestLimits {
pub max_batch_size: usize,
pub max_context_bytes: usize,
pub max_context_depth: usize,
pub max_context_keys: usize,
}
impl Default for RequestLimits {
fn default() -> Self {
Self {
max_batch_size: 1024,
max_context_bytes: 16 * 1024,
max_context_depth: 8,
max_context_keys: 64,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RequestContextFallbackReason {
NoSchema,
SchemaIncompatible,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct RequestContextStatus {
pub supported: bool,
pub schema_backed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_reason: Option<RequestContextFallbackReason>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StatusResponse {
pub policy_configuration: PoliciesMetadata,
pub parallel_configuration: serde_json::Value,
pub request_limits: RequestLimits,
pub request_context: RequestContextStatus,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_response_deserialization_current() {
let json = serde_json::json!({
"policy_configuration": {
"allow_upload": true,
"schema_validation_mode": "permissive",
"policies": {
"timestamp": "2026-01-01T00:00:00Z",
"sha256": "abc123",
"size": 1024,
"source": { "url": "https://example.com/policies" },
"refresh_frequency": 60,
"entries": 5,
"content": "permit(...)"
},
"labels": {
"timestamp": "2026-01-01T00:00:00Z",
"sha256": "def456",
"size": 256,
"entries": 2,
"content": "labels"
},
"schema": {
"timestamp": "2026-01-01T00:00:00Z",
"sha256": "",
"size": 0,
"entries": 0,
"content": ""
}
},
"parallel_configuration": {
"workers": 2,
"cpu_count": 8,
"rayon_threads": 4,
"par_threshold": 8,
"allow_parallel": true
},
"request_limits": {
"max_batch_size": 1024,
"max_context_bytes": 16384,
"max_context_depth": 8,
"max_context_keys": 64
},
"request_context": {
"supported": true,
"schema_backed": false,
"fallback_reason": "no_schema"
}
});
let status: StatusResponse = serde_json::from_value(json).unwrap();
assert!(status.policy_configuration.allow_upload);
assert_eq!(
status.policy_configuration.schema_validation_mode,
"permissive"
);
assert_eq!(status.policy_configuration.policies.entries, 5);
assert!(!status.policy_configuration.schema.timestamp.is_empty());
assert_eq!(status.request_limits.max_batch_size, 1024);
assert_eq!(status.request_limits.max_context_bytes, 16384);
assert!(status.request_context.supported);
assert!(!status.request_context.schema_backed);
assert_eq!(
status.request_context.fallback_reason,
Some(RequestContextFallbackReason::NoSchema)
);
}
#[test]
fn status_response_rejects_omitted_current_metadata() {
let json = serde_json::json!({
"policy_configuration": {
"allow_upload": true,
"policies": {
"timestamp": "2026-01-01T00:00:00Z",
"sha256": "abc",
"size": 100,
"entries": 3,
"content": "permit(...);"
},
"labels": {
"timestamp": "2026-01-01T00:00:00Z",
"sha256": "def",
"size": 0,
"entries": 0,
"content": ""
}
},
"parallel_configuration": { "cpu_count": 4 }
});
assert!(serde_json::from_value::<StatusResponse>(json).is_err());
}
#[test]
fn request_limits_default() {
let limits = RequestLimits::default();
assert_eq!(limits.max_batch_size, 1024);
assert_eq!(limits.max_context_bytes, 16 * 1024);
assert_eq!(limits.max_context_depth, 8);
assert_eq!(limits.max_context_keys, 64);
}
#[test]
fn request_context_status_default() {
let status = RequestContextStatus::default();
assert!(!status.supported);
assert!(!status.schema_backed);
assert_eq!(status.fallback_reason, None);
}
#[test]
fn metadata_source_accepts_current_endpoint_shape() {
let source: MetadataSource = serde_json::from_value(serde_json::json!({
"url": "https://example.com/policies.cedar"
}))
.unwrap();
assert_eq!(source.as_str(), "https://example.com/policies.cedar");
assert_eq!(
serde_json::to_value(source).unwrap(),
serde_json::json!({ "url": "https://example.com/policies.cedar" })
);
}
#[test]
fn metadata_source_rejects_invalid_urls() {
let error = serde_json::from_value::<MetadataSource>(serde_json::json!({
"url": "not a URL"
}))
.unwrap_err();
assert!(error.to_string().contains("relative URL without a base"));
}
#[test]
fn metadata_source_rejects_legacy_string_shape() {
assert!(
serde_json::from_value::<MetadataSource>(serde_json::json!(
"https://example.com/legacy.cedar"
))
.is_err()
);
}
#[test]
fn unknown_context_fallback_reason_is_forward_compatible() {
let reason: RequestContextFallbackReason =
serde_json::from_value(serde_json::json!("future_reason")).unwrap();
assert_eq!(reason, RequestContextFallbackReason::Unknown);
}
}