use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::pid_requirements::{Bo4ePrimitive, Bo4eRefType, Cardinality};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Bo4eCategory {
Bo,
Com,
Enum,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldDefinition {
pub name: String,
pub ref_type: Bo4eRefType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bo4eTypeDefinition {
pub type_name: String,
pub category: Bo4eCategory,
pub fields: Vec<FieldDefinition>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub enum_values: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Bo4eCatalog {
pub types: BTreeMap<String, Bo4eTypeDefinition>,
}
impl Bo4eCatalog {
pub fn get(&self, type_name: &str) -> Option<&Bo4eTypeDefinition> {
self.types.get(type_name)
}
pub fn list(&self) -> impl Iterator<Item = &Bo4eTypeDefinition> {
self.types.values()
}
pub fn is_empty(&self) -> bool {
self.types.is_empty()
}
}
#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
#[error("io error reading catalog source: {0}")]
Io(#[from] std::io::Error),
#[error("syn parse error: {0}")]
Syn(#[from] syn::Error),
}
pub fn build_catalog_from_source(source_root: &Path) -> Result<Bo4eCatalog, CatalogError> {
let mut types: BTreeMap<String, Bo4eTypeDefinition> = BTreeMap::new();
for (sub, category) in [
("bo", Bo4eCategory::Bo),
("com", Bo4eCategory::Com),
("enums", Bo4eCategory::Enum),
] {
let dir = source_root.join(sub);
if !dir.exists() {
continue;
}
for entry in std::fs::read_dir(&dir)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
for def in parse_rust_file(&path, category)? {
types.insert(def.type_name.clone(), def);
}
}
}
let mut catalog = Bo4eCatalog { types };
reclassify_enum_refs(&mut catalog);
Ok(catalog)
}
fn parse_rust_file(
path: &Path,
category: Bo4eCategory,
) -> Result<Vec<Bo4eTypeDefinition>, CatalogError> {
let src = std::fs::read_to_string(path)?;
let file = syn::parse_file(&src)?;
let mut out = Vec::new();
for item in file.items {
match item {
syn::Item::Struct(s) if matches!(category, Bo4eCategory::Bo | Bo4eCategory::Com) => {
out.push(struct_to_definition(&s, category));
}
syn::Item::Enum(e) if matches!(category, Bo4eCategory::Enum) => {
out.push(Bo4eTypeDefinition {
type_name: e.ident.to_string(),
category,
fields: vec![],
enum_values: e.variants.iter().map(|v| v.ident.to_string()).collect(),
});
}
_ => {}
}
}
Ok(out)
}
fn struct_to_definition(s: &syn::ItemStruct, category: Bo4eCategory) -> Bo4eTypeDefinition {
let type_name = s.ident.to_string();
let mut fields = Vec::new();
if let syn::Fields::Named(named) = &s.fields {
for f in &named.named {
if has_serde_flatten(&f.attrs) {
continue;
}
let name = f.ident.as_ref().map(|i| i.to_string()).unwrap_or_default();
let ref_type = classify_rust_type(&f.ty);
fields.push(FieldDefinition { name, ref_type });
}
}
Bo4eTypeDefinition {
type_name,
category,
fields,
enum_values: vec![],
}
}
fn has_serde_flatten(attrs: &[syn::Attribute]) -> bool {
for attr in attrs {
if !attr.path().is_ident("serde") {
continue;
}
let mut found = false;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("flatten") {
found = true;
}
Ok(())
});
if found {
return true;
}
}
false
}
fn classify_rust_type(ty: &syn::Type) -> Bo4eRefType {
let (inner, cardinality) = peel_wrappers(ty);
classify_inner(inner, cardinality)
}
fn peel_wrappers(ty: &syn::Type) -> (&syn::Type, Cardinality) {
if let Some(inner) = generic_inner(ty, "Box") {
return peel_wrappers(inner);
}
if let Some(inner) = generic_inner(ty, "Option") {
let (deeper, c) = peel_wrappers(inner);
let card = match c {
Cardinality {
min: 1,
max: Some(1),
} => Cardinality::OPTIONAL,
other => other,
};
return (deeper, card);
}
if let Some(inner) = generic_inner(ty, "Vec") {
let (deeper, _) = peel_wrappers(inner);
return (deeper, Cardinality::LIST);
}
(ty, Cardinality::REQUIRED)
}
fn generic_inner<'a>(ty: &'a syn::Type, wrapper: &str) -> Option<&'a syn::Type> {
let path = match ty {
syn::Type::Path(p) => &p.path,
_ => return None,
};
let seg = path.segments.last()?;
if seg.ident != wrapper {
return None;
}
let args = match &seg.arguments {
syn::PathArguments::AngleBracketed(a) => a,
_ => return None,
};
args.args.iter().find_map(|a| match a {
syn::GenericArgument::Type(t) => Some(t),
_ => None,
})
}
fn classify_inner(ty: &syn::Type, cardinality: Cardinality) -> Bo4eRefType {
let path = match ty {
syn::Type::Path(p) => &p.path,
_ => return Bo4eRefType::Unknown,
};
let last = match path.segments.last() {
Some(s) => s.ident.to_string(),
None => return Bo4eRefType::Unknown,
};
match last.as_str() {
"String" | "str" => Bo4eRefType::Primitive {
primitive: Bo4ePrimitive::String,
cardinality,
},
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "usize" | "isize" => {
Bo4eRefType::Primitive {
primitive: Bo4ePrimitive::Integer,
cardinality,
}
}
"f32" | "f64" => Bo4eRefType::Primitive {
primitive: Bo4ePrimitive::Decimal,
cardinality,
},
"bool" => Bo4eRefType::Primitive {
primitive: Bo4ePrimitive::Boolean,
cardinality,
},
"NaiveDate" => Bo4eRefType::Primitive {
primitive: Bo4ePrimitive::Date,
cardinality,
},
"DateTime" | "NaiveDateTime" | "OffsetDateTime" => Bo4eRefType::Primitive {
primitive: Bo4ePrimitive::DateTime,
cardinality,
},
other => Bo4eRefType::Object {
type_name: other.to_string(),
cardinality,
},
}
}
fn reclassify_enum_refs(catalog: &mut Bo4eCatalog) {
let enum_names: BTreeSet<String> = catalog
.types
.values()
.filter(|d| matches!(d.category, Bo4eCategory::Enum))
.map(|d| d.type_name.clone())
.collect();
for def in catalog.types.values_mut() {
for field in &mut def.fields {
if let Bo4eRefType::Object {
type_name,
cardinality,
} = &field.ref_type
{
if enum_names.contains(type_name) {
field.ref_type = Bo4eRefType::Enum {
type_name: type_name.clone(),
cardinality: *cardinality,
};
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_struct_to_definition() {
let src = r#"
pub struct Adresse {
pub strasse: Option<String>,
pub hausnummer: Option<String>,
pub ort: Option<String>,
pub aliases: Vec<String>,
}
"#;
let parsed: syn::ItemStruct = syn::parse_str(src).unwrap();
let def = struct_to_definition(&parsed, Bo4eCategory::Com);
assert_eq!(def.type_name, "Adresse");
assert_eq!(def.fields.len(), 4);
assert!(matches!(
def.fields[0].ref_type,
Bo4eRefType::Primitive {
primitive: Bo4ePrimitive::String,
cardinality: Cardinality {
min: 0,
max: Some(1)
}
}
));
assert!(matches!(
def.fields[3].ref_type,
Bo4eRefType::Primitive {
primitive: Bo4ePrimitive::String,
cardinality: Cardinality { min: 0, max: None }
}
));
}
#[test]
fn skips_serde_flatten_meta_field() {
let src = r#"
pub struct Marktlokation {
#[serde(flatten)]
pub meta: bo4e_core::Bo4eMeta,
pub marktlokations_id: Option<String>,
}
"#;
let parsed: syn::ItemStruct = syn::parse_str(src).unwrap();
let def = struct_to_definition(&parsed, Bo4eCategory::Bo);
assert_eq!(def.fields.len(), 1);
assert_eq!(def.fields[0].name, "marktlokations_id");
}
#[test]
fn classifies_object_reference() {
let src = r#"
pub struct Marktlokation {
pub adresse: Option<crate::Adresse>,
}
"#;
let parsed: syn::ItemStruct = syn::parse_str(src).unwrap();
let def = struct_to_definition(&parsed, Bo4eCategory::Bo);
let f = &def.fields[0];
match &f.ref_type {
Bo4eRefType::Object {
type_name,
cardinality,
} => {
assert_eq!(type_name, "Adresse");
assert_eq!(*cardinality, Cardinality::OPTIONAL);
}
other => panic!("expected Object, got {other:?}"),
}
}
#[test]
fn unwraps_box_indirection() {
let src = r#"
pub struct Marktteilnehmer {
pub geschaeftspartner: Option<Box<Geschaeftspartner>>,
pub kontaktwege: Vec<Box<Kontaktweg>>,
}
"#;
let parsed: syn::ItemStruct = syn::parse_str(src).unwrap();
let def = struct_to_definition(&parsed, Bo4eCategory::Bo);
match &def.fields[0].ref_type {
Bo4eRefType::Object {
type_name,
cardinality,
} => {
assert_eq!(type_name, "Geschaeftspartner");
assert_eq!(*cardinality, Cardinality::OPTIONAL);
}
other => panic!("expected Object<Geschaeftspartner>, got {other:?}"),
}
match &def.fields[1].ref_type {
Bo4eRefType::Object {
type_name,
cardinality,
} => {
assert_eq!(type_name, "Kontaktweg");
assert_eq!(*cardinality, Cardinality::LIST);
}
other => panic!("expected Object<Kontaktweg> as list, got {other:?}"),
}
}
#[test]
fn enum_reclassification_post_pass() {
let mut catalog = Bo4eCatalog::default();
catalog.types.insert(
"Sparte".into(),
Bo4eTypeDefinition {
type_name: "Sparte".into(),
category: Bo4eCategory::Enum,
fields: vec![],
enum_values: vec!["Strom".into(), "Gas".into()],
},
);
catalog.types.insert(
"Marktlokation".into(),
Bo4eTypeDefinition {
type_name: "Marktlokation".into(),
category: Bo4eCategory::Bo,
fields: vec![FieldDefinition {
name: "sparte".into(),
ref_type: Bo4eRefType::Object {
type_name: "Sparte".into(),
cardinality: Cardinality::OPTIONAL,
},
}],
enum_values: vec![],
},
);
reclassify_enum_refs(&mut catalog);
let f = &catalog.types["Marktlokation"].fields[0];
match &f.ref_type {
Bo4eRefType::Enum {
type_name,
cardinality,
} => {
assert_eq!(type_name, "Sparte");
assert_eq!(*cardinality, Cardinality::OPTIONAL);
}
other => panic!("expected Enum after reclassify, got {other:?}"),
}
}
}