use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use crate::pdt::{PdtTag, PdtTagSummary};
pub trait AsTagPair {
fn category(&self) -> &str;
fn value(&self) -> &str;
}
impl AsTagPair for PdtTag {
fn category(&self) -> &str {
&self.category
}
fn value(&self) -> &str {
&self.value
}
}
impl AsTagPair for PdtTagSummary {
fn category(&self) -> &str {
&self.category
}
fn value(&self) -> &str {
&self.value
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct EntityFile {
pub entity: EntityConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EntityConfig {
pub name: String,
pub slug: String,
#[serde(default)]
pub title_prefix: String,
#[serde(default = "default_handler")]
pub handler: String,
#[serde(default)]
pub default_tags: HashMap<String, String>,
#[serde(default)]
pub cedar: CedarActions,
#[serde(default)]
pub fields: HashMap<String, FieldConfig>,
#[serde(default)]
pub views: HashMap<String, ViewConfig>,
}
fn default_handler() -> String {
"generic".to_string()
}
pub fn slug_to_cedar_type(slug: &str) -> String {
slug.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|s| !s.is_empty())
.map(|s| {
let mut cs = s.chars();
match cs.next() {
Some(first) => first.to_ascii_uppercase().to_string() + cs.as_str(),
None => String::new(),
}
})
.collect()
}
#[derive(Debug, Clone, Deserialize)]
pub struct CedarActions {
#[serde(default)]
pub action_create: Option<String>,
#[serde(default)]
pub action_read: Option<String>,
#[serde(default)]
pub action_edit: Option<String>,
#[serde(default)]
pub action_delete: Option<String>,
}
impl Default for CedarActions {
fn default() -> Self {
Self {
action_create: None,
action_read: None,
action_edit: None,
action_delete: None,
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum FieldType {
Text,
Markdown,
Enum,
Date,
TagArray,
Relation,
Metadata,
}
impl Default for FieldType {
fn default() -> Self {
FieldType::Text
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct FieldConfig {
#[serde(rename = "type", default)]
pub field_type: FieldType,
#[serde(default)]
pub required: bool,
pub label: Option<String>,
pub map: Option<String>,
pub values: Option<Vec<String>>,
pub default: Option<String>,
#[serde(default)]
pub badge: bool,
#[serde(default)]
pub in_views: Vec<String>,
pub target_entity: Option<String>,
pub relation_type: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ViewConfig {
pub layout: String,
pub fields: Vec<String>,
#[serde(default)]
pub sort: Option<SortConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SortConfig {
pub field: String,
#[serde(default = "default_sort_order")]
pub order: String,
}
fn default_sort_order() -> String {
"desc".to_string()
}
pub fn load_entity_configs(dir: &str) -> Vec<EntityConfig> {
load_entity_configs_from_path(Path::new(dir))
}
pub fn load_entity_configs_from_path(dir: &Path) -> Vec<EntityConfig> {
let mut configs = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
tracing::warn!("Cannot read entity config directory {:?}: {}", dir, e);
return configs;
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
match std::fs::read_to_string(&path) {
Ok(content) => match toml::from_str::<EntityFile>(&content) {
Ok(file) => {
tracing::info!("Loaded entity config: {} ({})", file.entity.name, filename);
configs.push(file.entity);
}
Err(e) => {
tracing::error!("Failed to parse entity config {}: {}", filename, e);
}
},
Err(e) => {
tracing::error!("Cannot read entity config {}: {}", filename, e);
}
}
}
configs
}
impl EntityConfig {
pub fn get_field(&self, name: &str) -> Option<&FieldConfig> {
self.fields.get(name)
}
pub fn type_tag(&self) -> Option<&str> {
self.default_tags.get("type").map(|s| s.as_str())
}
pub fn tags_match<T: AsTagPair>(&self, asset_tags: &[T]) -> bool {
self.default_tags.iter().all(|(category, value)| {
asset_tags
.iter()
.any(|t| t.category() == category && t.value() == value)
})
}
pub fn fields_for_view(&self, view: &str) -> Vec<(&String, &FieldConfig)> {
self.fields
.iter()
.filter(|(_, fc)| fc.in_views.iter().any(|v| v == view))
.collect()
}
pub fn default_status(&self) -> Option<&str> {
self.default_tags
.get("status")
.map(|s| s.as_str())
.or_else(|| self.fields.get("status").and_then(|f| f.default.as_deref()))
}
pub fn status_values(&self) -> Option<&[String]> {
self.fields.get("status").and_then(|f| f.values.as_deref())
}
pub fn relation_fields(&self) -> Vec<(&String, &FieldConfig)> {
self.fields
.iter()
.filter(|(_, fc)| fc.field_type == FieldType::Relation)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_TOML: &str = r#"
[entity]
name = "Document"
slug = "document"
title_prefix = "Document: "
handler = "generic"
[entity.default_tags]
type = "document"
status = "draft"
[entity.cedar]
action_create = "CreateDocument"
action_read = "ViewDocument"
action_edit = "EditDocument"
action_delete = "DeleteDocument"
[entity.fields.title]
type = "text"
required = true
map = "title_suffix"
in_views = ["list", "detail"]
[entity.fields.content]
type = "markdown"
map = "content"
in_views = ["detail"]
[entity.fields.status]
type = "enum"
values = ["draft", "review", "approved", "published", "archived"]
default = "draft"
map = "tag:status"
badge = true
in_views = ["list", "detail"]
[entity.fields.attached_to]
type = "relation"
target_entity = "any"
relation_type = "related_to"
required = true
in_views = ["list", "detail"]
[entity.fields.created_at]
type = "date"
map = "created_at"
in_views = ["list", "detail"]
[entity.fields.updated_at]
type = "date"
map = "updated_at"
in_views = ["detail"]
[entity.views.list]
layout = "card"
fields = ["title", "status", "updated_at"]
[entity.views.detail]
layout = "detail"
fields = ["title", "status", "content", "attached_to", "created_at", "updated_at"]
"#;
#[test]
fn test_parse_document_config() {
let file: EntityFile = toml::from_str(SAMPLE_TOML).expect("Failed to parse sample TOML");
let entity = &file.entity;
assert_eq!(entity.name, "Document");
assert_eq!(entity.slug, "document");
assert_eq!(entity.title_prefix, "Document: ");
assert_eq!(entity.handler, "generic");
}
#[test]
fn test_default_tags() {
let file: EntityFile = toml::from_str(SAMPLE_TOML).unwrap();
let entity = &file.entity;
assert_eq!(entity.type_tag(), Some("document"));
assert_eq!(entity.default_status(), Some("draft"));
}
#[test]
fn test_tags_match_discriminates_shared_type() {
let entity_toml = r#"
[entity]
name = "Episodic Memory"
slug = "episodic-memory"
title_prefix = "Episode: "
handler = "generic"
[entity.default_tags]
type = "agent-memory"
memory-type = "episodic"
"#;
let entity: EntityFile = toml::from_str(entity_toml).unwrap();
let entity = &entity.entity;
let summary = |c: &str, v: &str| PdtTagSummary {
category: c.to_string(),
value: v.to_string(),
};
let full = |c: &str, v: &str| PdtTag {
id: "t".into(),
category: c.to_string(),
value: v.to_string(),
added_by: String::new(),
added_at: String::new(),
};
let episodic_summary = vec![
summary("type", "agent-memory"),
summary("memory-type", "episodic"),
];
assert!(entity.tags_match(&episodic_summary));
let semantic_summary = vec![
summary("type", "agent-memory"),
summary("memory-type", "semantic"),
];
assert!(!entity.tags_match(&semantic_summary));
let bare_summary = vec![summary("type", "agent-memory")];
assert!(!entity.tags_match(&bare_summary));
let noisy_summary = vec![
summary("type", "agent-memory"),
summary("memory-type", "episodic"),
summary("outcome", "success"),
];
assert!(entity.tags_match(&noisy_summary));
let episodic_full = vec![
full("type", "agent-memory"),
full("memory-type", "episodic"),
];
assert!(entity.tags_match(&episodic_full));
let semantic_full = vec![
full("type", "agent-memory"),
full("memory-type", "semantic"),
];
assert!(!entity.tags_match(&semantic_full));
}
#[test]
fn test_cedar_actions() {
let file: EntityFile = toml::from_str(SAMPLE_TOML).unwrap();
let entity = &file.entity;
assert_eq!(
entity.cedar.action_create.as_deref(),
Some("CreateDocument")
);
assert_eq!(entity.cedar.action_read.as_deref(), Some("ViewDocument"));
assert_eq!(entity.cedar.action_edit.as_deref(), Some("EditDocument"));
assert_eq!(
entity.cedar.action_delete.as_deref(),
Some("DeleteDocument")
);
}
#[test]
fn test_field_configs() {
let file: EntityFile = toml::from_str(SAMPLE_TOML).unwrap();
let entity = &file.entity;
let title = entity.get_field("title").unwrap();
assert_eq!(title.field_type, FieldType::Text);
assert!(title.required);
assert_eq!(title.map.as_deref(), Some("title_suffix"));
let content = entity.get_field("content").unwrap();
assert_eq!(content.field_type, FieldType::Markdown);
assert_eq!(content.map.as_deref(), Some("content"));
let status = entity.get_field("status").unwrap();
assert_eq!(status.field_type, FieldType::Enum);
assert_eq!(
status.values.as_deref(),
Some(
&[
"draft".to_string(),
"review".to_string(),
"approved".to_string(),
"published".to_string(),
"archived".to_string()
][..]
)
);
assert_eq!(status.default.as_deref(), Some("draft"));
assert!(status.badge);
let attached_to = entity.get_field("attached_to").unwrap();
assert_eq!(attached_to.field_type, FieldType::Relation);
assert_eq!(attached_to.target_entity.as_deref(), Some("any"));
assert_eq!(attached_to.relation_type.as_deref(), Some("related_to"));
let created_at = entity.get_field("created_at").unwrap();
assert_eq!(created_at.field_type, FieldType::Date);
assert_eq!(created_at.map.as_deref(), Some("created_at"));
}
#[test]
fn test_views() {
let file: EntityFile = toml::from_str(SAMPLE_TOML).unwrap();
let entity = &file.entity;
let list_view = entity.views.get("list").unwrap();
assert_eq!(list_view.layout, "card");
assert_eq!(list_view.fields, vec!["title", "status", "updated_at"]);
let detail_view = entity.views.get("detail").unwrap();
assert_eq!(detail_view.layout, "detail");
assert_eq!(
detail_view.fields,
vec![
"title",
"status",
"content",
"attached_to",
"created_at",
"updated_at"
]
);
}
#[test]
fn test_fields_for_view() {
let file: EntityFile = toml::from_str(SAMPLE_TOML).unwrap();
let entity = &file.entity;
let list_fields = entity.fields_for_view("list");
let list_names: Vec<&str> = list_fields.iter().map(|(n, _)| n.as_str()).collect();
assert!(list_names.contains(&"title"));
assert!(list_names.contains(&"status"));
assert!(!list_names.contains(&"content"));
let detail_fields = entity.fields_for_view("detail");
let detail_names: Vec<&str> = detail_fields.iter().map(|(n, _)| n.as_str()).collect();
assert!(detail_names.contains(&"content"));
assert!(detail_names.contains(&"attached_to"));
}
#[test]
fn test_relation_fields() {
let file: EntityFile = toml::from_str(SAMPLE_TOML).unwrap();
let entity = &file.entity;
let rels = entity.relation_fields();
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].0, "attached_to");
}
#[test]
fn test_status_values() {
let file: EntityFile = toml::from_str(SAMPLE_TOML).unwrap();
let entity = &file.entity;
let values = entity.status_values().unwrap();
assert_eq!(values.len(), 5);
assert!(values.contains(&"draft".to_string()));
assert!(values.contains(&"archived".to_string()));
}
#[test]
fn test_empty_cedar_defaults() {
let toml_str = r#"
[entity]
name = "Test"
slug = "test"
"#;
let file: EntityFile = toml::from_str(toml_str).unwrap();
assert!(file.entity.cedar.action_create.is_none());
assert!(file.entity.cedar.action_read.is_none());
}
#[test]
fn test_handler_default() {
let toml_str = r#"
[entity]
name = "Test"
slug = "test"
"#;
let file: EntityFile = toml::from_str(toml_str).unwrap();
assert_eq!(file.entity.handler, "generic");
}
#[test]
fn test_slug_to_cedar_type() {
assert_eq!(slug_to_cedar_type("semantic-memory"), "SemanticMemory");
assert_eq!(slug_to_cedar_type("agent-identity"), "AgentIdentity");
assert_eq!(slug_to_cedar_type("procedural-memory"), "ProceduralMemory");
assert_eq!(slug_to_cedar_type("episodic-memory"), "EpisodicMemory");
assert_eq!(slug_to_cedar_type(" semantic-memory "), "SemanticMemory");
assert_eq!(slug_to_cedar_type("a"), "A");
assert_eq!(slug_to_cedar_type("Semantic Memory"), "SemanticMemory");
}
}