use crate::error::SammError;
use crate::metamodel::{
Aspect, Characteristic, CharacteristicKind, ElementMetadata, Event, Operation, Property,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DtdlInterface {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "@id")]
id: String,
#[serde(rename = "@type")]
type_: DtdlType,
#[serde(rename = "displayName", skip_serializing_if = "Option::is_none")]
display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
comment: Option<String>,
#[serde(default)]
contents: Vec<DtdlContent>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
enum DtdlType {
Single(String),
Multiple(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DtdlContent {
#[serde(rename = "@type")]
type_: String,
name: String,
#[serde(rename = "displayName", skip_serializing_if = "Option::is_none")]
display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
schema: Option<DtdlSchema>,
#[serde(rename = "writable", skip_serializing_if = "Option::is_none")]
writable: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
enum DtdlSchema {
Primitive(String),
Object(serde_json::Value),
}
pub fn parse_dtdl_interface(json: &str) -> Result<Aspect, SammError> {
let interface: DtdlInterface = serde_json::from_str(json)
.map_err(|e| SammError::ParseError(format!("Invalid DTDL JSON: {}", e)))?;
if !interface.context.contains("dtmi:dtdl:context") {
return Err(SammError::ParseError(format!(
"Invalid DTDL context: {}. Expected dtmi:dtdl:context",
interface.context
)));
}
match &interface.type_ {
DtdlType::Single(t) if t == "Interface" => {}
DtdlType::Multiple(types) if types.contains(&"Interface".to_string()) => {}
_ => {
return Err(SammError::ParseError(format!(
"Not a DTDL Interface. Got type: {:?}",
interface.type_
)));
}
}
let urn = dtmi_to_urn(&interface.id)?;
let mut aspect = Aspect::new(urn);
if let Some(display_name) = interface.display_name {
aspect
.metadata
.add_preferred_name("en".to_string(), display_name);
}
if let Some(description) = interface.description {
aspect
.metadata
.add_description("en".to_string(), description);
}
if let Some(comment) = interface.comment {
if comment.starts_with("See also:") {
let refs = comment
.strip_prefix("See also:")
.unwrap_or("")
.trim()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
aspect.metadata.see_refs.extend(refs);
}
}
for content in interface.contents {
match content.type_.as_str() {
"Property" | "Telemetry" => {
let property = parse_property_content(&content, &interface.id)?;
aspect.add_property(property);
}
"Command" => {
let operation = parse_command_content(&content, &interface.id)?;
aspect.add_operation(operation);
}
_ => {
}
}
}
Ok(aspect)
}
fn parse_property_content(
content: &DtdlContent,
interface_id: &str,
) -> Result<Property, SammError> {
let property_urn = build_property_urn(interface_id, &content.name)?;
let mut property = Property::new(property_urn);
if let Some(display_name) = &content.display_name {
property
.metadata
.add_preferred_name("en".to_string(), display_name.clone());
}
if let Some(description) = &content.description {
property
.metadata
.add_description("en".to_string(), description.clone());
}
property.optional = content.type_ == "Telemetry";
if let Some(schema) = &content.schema {
let characteristic = parse_schema_to_characteristic(schema, interface_id, &content.name)?;
property.characteristic = Some(characteristic);
}
Ok(property)
}
fn parse_command_content(
content: &DtdlContent,
interface_id: &str,
) -> Result<Operation, SammError> {
let operation_urn = build_operation_urn(interface_id, &content.name)?;
let mut operation = Operation::new(operation_urn);
if let Some(display_name) = &content.display_name {
operation
.metadata
.add_preferred_name("en".to_string(), display_name.clone());
}
if let Some(description) = &content.description {
operation
.metadata
.add_description("en".to_string(), description.clone());
}
Ok(operation)
}
fn parse_schema_to_characteristic(
schema: &DtdlSchema,
interface_id: &str,
property_name: &str,
) -> Result<Characteristic, SammError> {
let schema_str = match schema {
DtdlSchema::Primitive(s) => s.clone(),
DtdlSchema::Object(_) => "object".to_string(),
};
let char_urn = format!(
"{}#{}Characteristic",
interface_id.split(';').next().unwrap_or(interface_id),
to_pascal_case(property_name)
);
let mut characteristic = Characteristic::new(char_urn, CharacteristicKind::Trait);
characteristic.data_type = Some(map_dtdl_to_xsd_type(&schema_str));
Ok(characteristic)
}
fn dtmi_to_urn(dtmi: &str) -> Result<String, SammError> {
if !dtmi.starts_with("dtmi:") {
return Err(SammError::ParseError(format!(
"Invalid DTMI: {}. Expected format: dtmi:namespace:name;version",
dtmi
)));
}
let without_prefix = dtmi
.strip_prefix("dtmi:")
.expect("DTMI should start with 'dtmi:' prefix (validated earlier)");
let parts: Vec<&str> = without_prefix.split(';').collect();
if parts.len() != 2 {
return Err(SammError::ParseError(format!(
"Invalid DTMI format: {}. Expected ';' separator for version",
dtmi
)));
}
let path = parts[0];
let major_version = parts[1];
if path.is_empty() {
return Err(SammError::ParseError(format!(
"Invalid DTMI: {}. Path cannot be empty",
dtmi
)));
}
let path_parts: Vec<&str> = path.split(':').collect();
if path_parts.len() < 2 {
return Err(SammError::ParseError(format!(
"Invalid DTMI path: {}. Expected at least namespace:name",
path
)));
}
let name = path_parts.last().expect("collection should not be empty");
let namespace_parts = &path_parts[..path_parts.len() - 1];
let namespace = namespace_parts.join(".");
let version = format!("{}.0.0", major_version);
let urn = format!("urn:samm:{}:{}#{}", namespace, version, name);
Ok(urn)
}
fn build_property_urn(interface_dtmi: &str, property_name: &str) -> Result<String, SammError> {
let base_urn = dtmi_to_urn(interface_dtmi)?;
let namespace_version = base_urn
.strip_prefix("urn:samm:")
.and_then(|s| s.split('#').next())
.ok_or_else(|| SammError::ParseError("Invalid URN structure".to_string()))?;
Ok(format!("urn:samm:{}#{}", namespace_version, property_name))
}
fn build_operation_urn(interface_dtmi: &str, operation_name: &str) -> Result<String, SammError> {
build_property_urn(interface_dtmi, operation_name)
}
fn map_dtdl_to_xsd_type(dtdl_type: &str) -> String {
let base_type = if dtdl_type.starts_with("dtmi:dtdl:instance:Schema:") {
dtdl_type
.strip_prefix("dtmi:dtdl:instance:Schema:")
.and_then(|s| s.split(';').next())
.unwrap_or("Object")
} else {
dtdl_type
};
let xsd_type = match base_type {
"integer" | "int" => "xsd:int",
"long" => "xsd:long",
"float" => "xsd:float",
"double" => "xsd:double",
"boolean" | "bool" => "xsd:boolean",
"string" => "xsd:string",
"dateTime" => "xsd:dateTime",
"date" => "xsd:date",
"time" => "xsd:time",
"duration" => "xsd:duration",
"Object" => "xsd:string", "Array" => "xsd:string", "Map" => "xsd:string",
_ => "xsd:string",
};
xsd_type.to_string()
}
fn to_pascal_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize_next = true;
for ch in s.chars() {
if ch == '_' || ch == '-' {
capitalize_next = true;
} else if capitalize_next {
result.push(
ch.to_uppercase()
.next()
.expect("to_uppercase() always returns at least one character"),
);
capitalize_next = false;
} else {
result.push(ch);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metamodel::ModelElement;
#[test]
fn test_dtmi_to_urn_conversion() {
assert_eq!(
dtmi_to_urn("dtmi:com:example:Movement;1").expect("operation should succeed"),
"urn:samm:com.example:1.0.0#Movement"
);
assert_eq!(
dtmi_to_urn("dtmi:org:eclipse:esmf:Aspect;2").expect("operation should succeed"),
"urn:samm:org.eclipse.esmf:2.0.0#Aspect"
);
assert_eq!(
dtmi_to_urn("dtmi:io:github:oxirs:TestAspect;0").expect("operation should succeed"),
"urn:samm:io.github.oxirs:0.0.0#TestAspect"
);
}
#[test]
fn test_dtmi_to_urn_invalid() {
assert!(dtmi_to_urn("invalid:dtmi").is_err());
assert!(dtmi_to_urn("dtmi:no-semicolon").is_err());
assert!(dtmi_to_urn("dtmi:;1").is_err());
}
#[test]
fn test_pascal_case_conversion() {
assert_eq!(to_pascal_case("speed"), "Speed");
assert_eq!(to_pascal_case("current_speed"), "CurrentSpeed");
assert_eq!(to_pascal_case("currentSpeed"), "CurrentSpeed");
assert_eq!(to_pascal_case("GPS_coordinates"), "GPSCoordinates");
}
#[test]
fn test_dtdl_to_xsd_mapping() {
assert_eq!(map_dtdl_to_xsd_type("string"), "xsd:string");
assert_eq!(map_dtdl_to_xsd_type("integer"), "xsd:int");
assert_eq!(map_dtdl_to_xsd_type("float"), "xsd:float");
assert_eq!(map_dtdl_to_xsd_type("double"), "xsd:double");
assert_eq!(map_dtdl_to_xsd_type("boolean"), "xsd:boolean");
assert_eq!(map_dtdl_to_xsd_type("dateTime"), "xsd:dateTime");
assert_eq!(
map_dtdl_to_xsd_type("dtmi:dtdl:instance:Schema:Object;3"),
"xsd:string"
);
}
#[test]
fn test_parse_minimal_interface() {
let dtdl = r#"{
"@context": "dtmi:dtdl:context;3",
"@id": "dtmi:com:example:Movement;1",
"@type": "Interface",
"displayName": "Movement"
}"#;
let aspect = parse_dtdl_interface(dtdl).expect("DTDL parsing should succeed");
assert_eq!(aspect.name(), "Movement");
assert_eq!(aspect.metadata.urn, "urn:samm:com.example:1.0.0#Movement");
}
#[test]
fn test_parse_interface_with_description() {
let dtdl = r#"{
"@context": "dtmi:dtdl:context;3",
"@id": "dtmi:com:example:Movement;1",
"@type": "Interface",
"displayName": "Movement",
"description": "Vehicle movement tracking"
}"#;
let aspect = parse_dtdl_interface(dtdl).expect("DTDL parsing should succeed");
let desc = aspect.metadata.get_description("en");
assert_eq!(desc, Some("Vehicle movement tracking"));
}
#[test]
fn test_parse_interface_with_property() {
let dtdl = r#"{
"@context": "dtmi:dtdl:context;3",
"@id": "dtmi:com:example:Movement;1",
"@type": "Interface",
"displayName": "Movement",
"contents": [
{
"@type": "Property",
"name": "speed",
"displayName": "speed",
"description": "Current speed",
"schema": "float"
}
]
}"#;
let aspect = parse_dtdl_interface(dtdl).expect("DTDL parsing should succeed");
assert_eq!(aspect.properties().len(), 1);
let prop = &aspect.properties()[0];
assert_eq!(prop.name(), "speed");
assert!(!prop.optional); assert!(prop.characteristic.is_some());
let char = prop
.characteristic
.as_ref()
.expect("reference should be available");
assert_eq!(char.data_type, Some("xsd:float".to_string()));
}
#[test]
fn test_parse_interface_with_telemetry() {
let dtdl = r#"{
"@context": "dtmi:dtdl:context;3",
"@id": "dtmi:com:example:Sensor;1",
"@type": "Interface",
"displayName": "Sensor",
"contents": [
{
"@type": "Telemetry",
"name": "temperature",
"schema": "double"
}
]
}"#;
let aspect = parse_dtdl_interface(dtdl).expect("DTDL parsing should succeed");
assert_eq!(aspect.properties().len(), 1);
let prop = &aspect.properties()[0];
assert_eq!(prop.name(), "temperature");
assert!(prop.optional); }
#[test]
fn test_parse_interface_with_command() {
let dtdl = r#"{
"@context": "dtmi:dtdl:context;3",
"@id": "dtmi:com:example:Movement;1",
"@type": "Interface",
"displayName": "Movement",
"contents": [
{
"@type": "Command",
"name": "emergencyStop",
"displayName": "Emergency Stop",
"description": "Stops the vehicle immediately"
}
]
}"#;
let aspect = parse_dtdl_interface(dtdl).expect("DTDL parsing should succeed");
assert_eq!(aspect.operations().len(), 1);
let op = &aspect.operations()[0];
assert_eq!(op.name(), "emergencyStop");
let display_name = op.metadata.get_preferred_name("en");
assert_eq!(display_name, Some("Emergency Stop"));
}
#[test]
fn test_parse_invalid_json() {
let invalid = "{ invalid json }";
assert!(parse_dtdl_interface(invalid).is_err());
}
#[test]
fn test_parse_non_interface() {
let dtdl = r#"{
"@context": "dtmi:dtdl:context;3",
"@id": "dtmi:com:example:Thing;1",
"@type": "NotAnInterface"
}"#;
assert!(parse_dtdl_interface(dtdl).is_err());
}
}