use crate::error::SammError;
use crate::metamodel::{Aspect, CharacteristicKind, ModelElement, Property};
#[derive(Debug, Clone)]
pub struct DtdlOptions {
pub version: u8,
pub include_descriptions: bool,
pub include_display_names: bool,
pub compact: bool,
pub all_writable: bool,
}
impl Default for DtdlOptions {
fn default() -> Self {
Self {
version: 3,
include_descriptions: true,
include_display_names: true,
compact: false,
all_writable: false,
}
}
}
pub fn generate_dtdl(aspect: &Aspect) -> Result<String, SammError> {
generate_dtdl_with_options(aspect, DtdlOptions::default())
}
pub fn generate_dtdl_with_options(
aspect: &Aspect,
options: DtdlOptions,
) -> Result<String, SammError> {
let aspect_name = aspect.name();
let dtmi = to_dtmi(&aspect.metadata.urn)?;
let indent = if options.compact { "" } else { " " };
let nl = if options.compact { "" } else { "\n" };
let mut dtdl = String::new();
dtdl.push_str(&format!("{{{nl}"));
dtdl.push_str(&format!(
"{indent}\"@context\": \"dtmi:dtdl:context;{}\",{nl}",
options.version
));
dtdl.push_str(&format!("{indent}\"@id\": \"{}\",{nl}", dtmi));
dtdl.push_str(&format!("{indent}\"@type\": \"Interface\",{nl}"));
if options.include_display_names {
let display_name = aspect
.metadata
.get_preferred_name("en")
.unwrap_or(&aspect_name);
dtdl.push_str(&format!(
"{indent}\"displayName\": \"{}\",{nl}",
display_name
));
}
if options.include_descriptions {
if let Some(desc) = aspect.metadata.get_description("en") {
dtdl.push_str(&format!(
"{indent}\"description\": \"{}\",{nl}",
escape_json(desc)
));
}
}
if !aspect.metadata.see_refs.is_empty() {
let see_also = aspect.metadata.see_refs.join(", ");
dtdl.push_str(&format!(
"{indent}\"comment\": \"See also: {}\",{nl}",
escape_json(&see_also)
));
}
dtdl.push_str(&format!("{indent}\"contents\": [{nl}"));
let mut contents = Vec::new();
for prop in aspect.properties() {
contents.push(generate_property_content(prop, &options, indent, nl)?);
}
for op in aspect.operations() {
let op_name = to_camel_case(&op.name());
let op_default_name = op.name();
let op_display_name = op
.metadata
.get_preferred_name("en")
.unwrap_or(&op_default_name);
let mut cmd = format!("{indent}{indent}{{{nl}");
cmd.push_str(&format!(
"{indent}{indent}{indent}\"@type\": \"Command\",{nl}"
));
cmd.push_str(&format!(
"{indent}{indent}{indent}\"name\": \"{}\"",
op_name
));
if options.include_display_names {
cmd.push_str(&format!(",{nl}"));
cmd.push_str(&format!(
"{indent}{indent}{indent}\"displayName\": \"{}\"",
op_display_name
));
}
if options.include_descriptions {
if let Some(desc) = op.metadata.get_description("en") {
cmd.push_str(&format!(",{nl}"));
cmd.push_str(&format!(
"{indent}{indent}{indent}\"description\": \"{}\"",
escape_json(desc)
));
}
}
cmd.push_str(nl);
cmd.push_str(&format!("{indent}{indent}}}"));
contents.push(cmd);
}
for event in aspect.events() {
let event_name = to_camel_case(&event.name());
let event_default_name = event.name();
let event_display_name = event
.metadata
.get_preferred_name("en")
.unwrap_or(&event_default_name);
let mut telemetry = format!("{indent}{indent}{{{nl}");
telemetry.push_str(&format!(
"{indent}{indent}{indent}\"@type\": \"Telemetry\",{nl}"
));
telemetry.push_str(&format!(
"{indent}{indent}{indent}\"name\": \"{}\"",
event_name
));
if options.include_display_names {
telemetry.push_str(&format!(",{nl}"));
telemetry.push_str(&format!(
"{indent}{indent}{indent}\"displayName\": \"{}\"",
event_display_name
));
}
if options.include_descriptions {
if let Some(desc) = event.metadata.get_description("en") {
telemetry.push_str(&format!(",{nl}"));
telemetry.push_str(&format!(
"{indent}{indent}{indent}\"description\": \"{}\"",
escape_json(desc)
));
}
}
telemetry.push_str(&format!(",{nl}"));
telemetry.push_str(&format!(
"{indent}{indent}{indent}\"schema\": \"dtmi:dtdl:instance:Schema:Object;3\"{nl}"
));
telemetry.push_str(&format!("{indent}{indent}}}"));
contents.push(telemetry);
}
dtdl.push_str(&contents.join(&format!(",{nl}")));
dtdl.push_str(nl);
dtdl.push_str(&format!("{indent}]{nl}"));
dtdl.push_str(&format!("}}{nl}"));
Ok(dtdl)
}
fn generate_property_content(
prop: &Property,
options: &DtdlOptions,
indent: &str,
nl: &str,
) -> Result<String, SammError> {
let prop_name = to_camel_case(&prop.effective_name());
let default_name = prop.name();
let prop_display_name = prop
.metadata
.get_preferred_name("en")
.unwrap_or(&default_name);
let mut content = format!("{indent}{indent}{{{nl}");
let is_writable = options.all_writable || !prop.optional;
let content_type = if is_writable { "Property" } else { "Telemetry" };
content.push_str(&format!(
"{indent}{indent}{indent}\"@type\": \"{}\",{nl}",
content_type
));
content.push_str(&format!(
"{indent}{indent}{indent}\"name\": \"{}\"",
prop_name
));
if options.include_display_names {
content.push_str(&format!(",{nl}"));
content.push_str(&format!(
"{indent}{indent}{indent}\"displayName\": \"{}\"",
prop_display_name
));
}
if options.include_descriptions {
if let Some(desc) = prop.metadata.get_description("en") {
content.push_str(&format!(",{nl}"));
content.push_str(&format!(
"{indent}{indent}{indent}\"description\": \"{}\"",
escape_json(desc)
));
}
}
let schema = if let Some(characteristic) = &prop.characteristic {
map_characteristic_to_schema(characteristic)?
} else {
"string".to_string()
};
content.push_str(&format!(",{nl}"));
content.push_str(&format!(
"{indent}{indent}{indent}\"schema\": \"{}\"{nl}",
schema
));
content.push_str(&format!("{indent}{indent}}}"));
Ok(content)
}
fn map_characteristic_to_schema(
characteristic: &crate::metamodel::Characteristic,
) -> Result<String, SammError> {
if let Some(data_type) = &characteristic.data_type {
return map_xsd_to_dtdl_schema(data_type);
}
match &characteristic.kind {
CharacteristicKind::Trait => Ok("string".to_string()),
CharacteristicKind::Quantifiable { .. } => Ok("double".to_string()),
CharacteristicKind::Measurement { .. } => Ok("double".to_string()),
CharacteristicKind::Enumeration { values } => {
Ok("string".to_string())
}
CharacteristicKind::State { .. } => Ok("string".to_string()),
CharacteristicKind::Duration { .. } => Ok("duration".to_string()),
CharacteristicKind::Collection { .. } => {
Ok("dtmi:dtdl:instance:Schema:Array;3".to_string())
}
CharacteristicKind::List { .. } => Ok("dtmi:dtdl:instance:Schema:Array;3".to_string()),
CharacteristicKind::Set { .. } => Ok("dtmi:dtdl:instance:Schema:Array;3".to_string()),
CharacteristicKind::SortedSet { .. } => Ok("dtmi:dtdl:instance:Schema:Array;3".to_string()),
CharacteristicKind::TimeSeries { .. } => {
Ok("dtmi:dtdl:instance:Schema:Array;3".to_string())
}
CharacteristicKind::Code => Ok("string".to_string()),
CharacteristicKind::Either { .. } => {
Ok("dtmi:dtdl:instance:Schema:Object;3".to_string())
}
CharacteristicKind::SingleEntity { .. } => {
Ok("dtmi:dtdl:instance:Schema:Object;3".to_string())
}
CharacteristicKind::StructuredValue { .. } => {
Ok("dtmi:dtdl:instance:Schema:Object;3".to_string())
}
}
}
fn map_xsd_to_dtdl_schema(xsd_type: &str) -> Result<String, SammError> {
let after_hash = xsd_type.split('#').next_back().unwrap_or(xsd_type);
let base_type = after_hash.split(':').next_back().unwrap_or(after_hash);
let dtdl_type = match base_type {
"int" | "integer" | "short" | "byte" => "integer",
"long" => "long",
"float" | "decimal" => "float",
"double" => "double",
"boolean" | "bool" => "boolean",
"string" | "normalizedString" | "token" | "language" | "Name" | "NCName" => "string",
"dateTime" => "dateTime",
"date" => "date",
"time" => "time",
"duration" => "duration",
_ => "string",
};
Ok(dtdl_type.to_string())
}
fn to_dtmi(urn: &str) -> Result<String, SammError> {
if !urn.starts_with("urn:samm:") {
return Err(SammError::Generation(format!(
"Invalid SAMM URN: {}. Expected format: urn:samm:namespace:version#name",
urn
)));
}
let without_prefix = urn
.strip_prefix("urn:samm:")
.expect("prefix should be present");
let parts: Vec<&str> = without_prefix.split('#').collect();
if parts.len() != 2 {
return Err(SammError::Generation(format!(
"Invalid SAMM URN format: {}. Expected '#' separator",
urn
)));
}
let namespace_version = parts[0];
let name = parts[1];
let ns_parts: Vec<&str> = namespace_version.rsplitn(2, ':').collect();
if ns_parts.len() != 2 {
return Err(SammError::Generation(format!(
"Invalid SAMM URN format: {}. Expected ':' separator for version",
urn
)));
}
let version = ns_parts[0];
let namespace = ns_parts[1];
let namespace_dtmi = namespace.replace('.', ":");
let major_version = version
.split('.')
.next()
.ok_or_else(|| SammError::Generation(format!("Invalid version: {}", version)))?;
let dtmi = format!("dtmi:{}:{};{}", namespace_dtmi, name, major_version);
Ok(dtmi)
}
fn to_camel_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize_next = false;
for ch in s.chars() {
if ch == '_' || ch == '-' {
capitalize_next = true;
} else if capitalize_next {
result.push(
ch.to_uppercase()
.next()
.expect("uppercase should produce a character"),
);
capitalize_next = false;
} else {
result.push(ch);
}
}
result
}
fn escape_json(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metamodel::{Characteristic, ElementMetadata, Operation};
#[test]
fn test_to_dtmi_conversion() {
assert_eq!(
to_dtmi("urn:samm:com.example:1.0.0#Movement").expect("DTMI conversion should succeed"),
"dtmi:com:example:Movement;1"
);
assert_eq!(
to_dtmi("urn:samm:org.eclipse.esmf:2.3.0#Aspect")
.expect("DTMI conversion should succeed"),
"dtmi:org:eclipse:esmf:Aspect;2"
);
assert_eq!(
to_dtmi("urn:samm:io.github.oxirs:0.1.0#TestAspect")
.expect("DTMI conversion should succeed"),
"dtmi:io:github:oxirs:TestAspect;0"
);
}
#[test]
fn test_to_dtmi_invalid_urn() {
assert!(to_dtmi("invalid:urn").is_err());
assert!(to_dtmi("urn:samm:no-hash").is_err());
assert!(to_dtmi("urn:samm:no-version#Name").is_err());
}
#[test]
fn test_camel_case_conversion() {
assert_eq!(to_camel_case("movement_aspect"), "movementAspect");
assert_eq!(to_camel_case("position"), "position");
assert_eq!(to_camel_case("current_speed"), "currentSpeed");
assert_eq!(to_camel_case("current-speed"), "currentSpeed");
assert_eq!(to_camel_case("GPS_coordinates"), "GPSCoordinates");
}
#[test]
fn test_xsd_to_dtdl_schema_mapping() {
assert_eq!(
map_xsd_to_dtdl_schema("string").expect("XSD mapping should succeed"),
"string"
);
assert_eq!(
map_xsd_to_dtdl_schema("int").expect("XSD mapping should succeed"),
"integer"
);
assert_eq!(
map_xsd_to_dtdl_schema("xsd:int").expect("XSD mapping should succeed"),
"integer"
);
assert_eq!(
map_xsd_to_dtdl_schema("float").expect("XSD mapping should succeed"),
"float"
);
assert_eq!(
map_xsd_to_dtdl_schema("xsd:float").expect("XSD mapping should succeed"),
"float"
);
assert_eq!(
map_xsd_to_dtdl_schema("boolean").expect("XSD mapping should succeed"),
"boolean"
);
assert_eq!(
map_xsd_to_dtdl_schema("dateTime").expect("XSD mapping should succeed"),
"dateTime"
);
assert_eq!(
map_xsd_to_dtdl_schema("http://www.w3.org/2001/XMLSchema#double")
.expect("XSD mapping should succeed"),
"double"
);
}
#[test]
fn test_escape_json() {
assert_eq!(escape_json("hello"), "hello");
assert_eq!(escape_json("hello \"world\""), "hello \\\"world\\\"");
assert_eq!(escape_json("line1\nline2"), "line1\\nline2");
assert_eq!(escape_json("tab\there"), "tab\\there");
}
#[test]
fn test_basic_aspect_generation() {
let aspect = Aspect::new("urn:samm:com.example:1.0.0#Movement".to_string());
let dtdl = generate_dtdl(&aspect).expect("DTDL generation should succeed");
assert!(dtdl.contains("\"@context\": \"dtmi:dtdl:context;3\""));
assert!(dtdl.contains("\"@id\": \"dtmi:com:example:Movement;1\""));
assert!(dtdl.contains("\"@type\": \"Interface\""));
}
#[test]
fn test_aspect_with_property() {
let mut aspect = Aspect::new("urn:samm:com.example:1.0.0#Movement".to_string());
let mut prop = Property::new("urn:samm:com.example:1.0.0#speed".to_string());
let mut char = Characteristic::new(
"urn:samm:com.example:1.0.0#SpeedCharacteristic".to_string(),
CharacteristicKind::Measurement {
unit: "unit:kilometrePerHour".to_string(),
},
);
char.data_type = Some("xsd:float".to_string());
prop.characteristic = Some(char);
aspect.add_property(prop);
let dtdl = generate_dtdl(&aspect).expect("DTDL generation should succeed");
assert!(dtdl.contains("\"name\": \"speed\""));
assert!(dtdl.contains("\"schema\": \"float\""));
}
#[test]
fn test_aspect_with_operation() {
let mut aspect = Aspect::new("urn:samm:com.example:1.0.0#Movement".to_string());
let op = Operation::new("urn:samm:com.example:1.0.0#stop".to_string());
aspect.add_operation(op);
let dtdl = generate_dtdl(&aspect).expect("DTDL generation should succeed");
assert!(dtdl.contains("\"@type\": \"Command\""));
assert!(dtdl.contains("\"name\": \"stop\""));
}
#[test]
fn test_compact_output() {
let aspect = Aspect::new("urn:samm:com.example:1.0.0#Movement".to_string());
let options = DtdlOptions {
compact: true,
..Default::default()
};
let dtdl = generate_dtdl_with_options(&aspect, options).expect("generation should succeed");
assert!(!dtdl.contains(" "));
}
#[test]
fn test_options_include_descriptions() {
let mut aspect = Aspect::new("urn:samm:com.example:1.0.0#Movement".to_string());
aspect
.metadata
.add_description("en".to_string(), "Movement tracking aspect".to_string());
let options = DtdlOptions {
include_descriptions: true,
..Default::default()
};
let dtdl = generate_dtdl_with_options(&aspect, options).expect("generation should succeed");
assert!(dtdl.contains("\"description\": \"Movement tracking aspect\""));
let options_no_desc = DtdlOptions {
include_descriptions: false,
..Default::default()
};
let dtdl_no_desc = generate_dtdl_with_options(&aspect, options_no_desc)
.expect("generation should succeed");
assert!(!dtdl_no_desc.contains("\"description\""));
}
}