use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::validation::{
CedarIpAddr, CedarTypeName, EntityId, ValidationError, validate_attribute_name,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(tag = "type", content = "value")]
pub enum AttrValue {
String(String),
Bool(bool),
Long(i64),
Ip(CedarIpAddr),
Set(Vec<AttrValue>),
}
impl AttrValue {
pub fn ip(value: impl Into<String>) -> Result<Self, ValidationError> {
CedarIpAddr::new(value).map(Self::Ip)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Resource {
kind: CedarTypeName,
id: EntityId,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
attrs: BTreeMap<String, AttrValue>,
}
impl Resource {
pub fn new(kind: impl Into<String>, id: impl Into<String>) -> Self {
Self {
kind: CedarTypeName::new(kind),
id: EntityId::new(id),
attrs: BTreeMap::new(),
}
}
pub fn try_new(
kind: impl Into<String>,
id: impl Into<String>,
) -> Result<Self, ValidationError> {
let resource = Self::new(kind, id);
resource.validate()?;
Ok(resource)
}
pub fn with_attr(mut self, key: impl Into<String>, value: AttrValue) -> Self {
self.attrs.insert(key.into(), value);
self
}
pub fn try_with_attr(
self,
key: impl Into<String>,
value: AttrValue,
) -> Result<Self, ValidationError> {
let resource = self.with_attr(key, value);
resource.validate()?;
Ok(resource)
}
pub fn kind(&self) -> &str {
self.kind.as_str()
}
pub fn id(&self) -> &str {
self.id.as_str()
}
pub fn attrs(&self) -> &BTreeMap<String, AttrValue> {
&self.attrs
}
pub fn attr(&self, key: &str) -> Option<&AttrValue> {
self.attrs.get(key)
}
pub fn validate(&self) -> Result<(), ValidationError> {
self.kind.validate("resource.kind")?;
self.id.validate("resource.id")?;
for key in self.attrs.keys() {
validate_attribute_name(key, "resource.attrs")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn resource_without_attrs() {
let resource = Resource::new("Host", "web-01");
let json = serde_json::to_value(&resource).unwrap();
assert_eq!(json["kind"], "Host");
assert_eq!(json["id"], "web-01");
assert!(json.get("attrs").is_none());
}
#[test]
fn resource_with_attrs() {
let resource = Resource::new("Document", "doc1")
.with_attr("owner", AttrValue::String("alice".to_string()))
.with_attr("public", AttrValue::Bool(false))
.with_attr("priority", AttrValue::Long(5))
.with_attr("ip", AttrValue::ip("10.0.0.1").unwrap());
let json = serde_json::to_value(&resource).unwrap();
assert!(json["attrs"].is_object());
assert_eq!(json["attrs"]["owner"]["type"], "String");
assert_eq!(json["attrs"]["owner"]["value"], "alice");
assert_eq!(json["attrs"]["public"]["type"], "Bool");
}
#[rstest]
#[case::string(AttrValue::String("hello".to_string()))]
#[case::bool_true(AttrValue::Bool(true))]
#[case::bool_false(AttrValue::Bool(false))]
#[case::long_positive(AttrValue::Long(42))]
#[case::long_negative(AttrValue::Long(-1))]
#[case::long_zero(AttrValue::Long(0))]
#[case::ip_v4(AttrValue::ip("192.168.1.1").unwrap())]
#[case::ip_cidr(AttrValue::ip("10.0.0.0/8").unwrap())]
#[case::set(AttrValue::Set(vec![AttrValue::String("a".to_string()), AttrValue::String("b".to_string())]))]
#[case::empty_set(AttrValue::Set(vec![]))]
#[case::nested_set(AttrValue::Set(vec![AttrValue::Set(vec![AttrValue::Long(1)])]))]
fn attrvalue_roundtrip(#[case] val: AttrValue) {
let json = serde_json::to_value(&val).unwrap();
let deserialized: AttrValue = serde_json::from_value(json).unwrap();
assert_eq!(val, deserialized);
}
#[test]
fn resource_roundtrip() {
let resource =
Resource::new("Host", "web-01").with_attr("ip", AttrValue::ip("10.0.0.1").unwrap());
let json = serde_json::to_value(&resource).unwrap();
let deserialized: Resource = serde_json::from_value(json).unwrap();
assert_eq!(resource, deserialized);
}
}