use anyhow::{bail, Context, Result};
use serde_json::{Map, Value};
const ENVELOPE_KEYS: &[&str] = &["ocel2", "ocel", "OCEL2", "ocel_log", "log"];
pub fn parse_ocel_tolerant(json_str: &str) -> Result<wasm4pm::models::OCEL> {
if let Ok(ocel) = serde_json::from_str::<wasm4pm::models::OCEL>(json_str) {
if !ocel.events.is_empty() {
return Ok(ocel);
}
}
let root: Value = serde_json::from_str(json_str).context("input is not valid JSON at all")?;
let Some(root_obj) = root.as_object() else {
bail!("OCEL input is neither a recognized native OCEL object nor a JSON object envelope");
};
for key in ENVELOPE_KEYS {
if let Some(nested) = root_obj.get(*key) {
if let Some(ocel) = try_parse_nested(nested)? {
return Ok(ocel);
}
}
}
bail!(
"could not find a usable OCEL payload: direct parse yielded zero events, and no \
recognized envelope key ({:?}) contained a nested OCEL payload with events",
ENVELOPE_KEYS
);
}
fn try_parse_nested(nested: &Value) -> Result<Option<wasm4pm::models::OCEL>> {
if !nested.is_object() {
return Ok(None);
}
if let Ok(ocel) = serde_json::from_value::<wasm4pm::models::OCEL>(nested.clone()) {
if !ocel.events.is_empty() {
return Ok(Some(ocel));
}
}
let normalized = normalize_ocel_value(nested);
let ocel: wasm4pm::models::OCEL = serde_json::from_value(normalized)
.context("nested OCEL payload did not match the expected OCEL 2.0 shape")?;
if ocel.events.is_empty() {
return Ok(None);
}
Ok(Some(ocel))
}
fn normalize_ocel_value(value: &Value) -> Value {
let obj = match value.as_object() {
Some(o) => o,
None => return value.clone(),
};
let mut out = Map::new();
for (key, val) in obj {
match key.as_str() {
"eventTypes" | "objectTypes" | "event_types" | "object_types" => {
out.insert(key.clone(), normalize_type_names(val));
}
"events" | "objects" => {
out.insert(key.clone(), normalize_entry_array(val));
}
_ => {
out.insert(key.clone(), val.clone());
}
}
}
Value::Object(out)
}
fn normalize_type_names(val: &Value) -> Value {
match val {
Value::Object(map) => Value::Array(map.keys().map(|k| Value::String(k.clone())).collect()),
other => other.clone(),
}
}
fn normalize_entry_array(val: &Value) -> Value {
match val {
Value::Array(items) => Value::Array(items.iter().map(normalize_entry_keys).collect()),
other => other.clone(),
}
}
fn normalize_entry_keys(entry: &Value) -> Value {
let Some(obj) = entry.as_object() else {
return entry.clone();
};
let mut out = Map::new();
for (key, val) in obj {
let stripped = key.strip_prefix("ocel:").unwrap_or(key);
let normalized_val = if stripped == "attributes" {
normalize_attributes_value(val)
} else {
val.clone()
};
out.insert(stripped.to_string(), normalized_val);
}
Value::Object(out)
}
fn normalize_attributes_value(val: &Value) -> Value {
match val {
Value::Object(map) => Value::Array(
map.iter()
.map(|(name, value)| {
let mut pair = Map::new();
pair.insert("name".to_string(), Value::String(name.clone()));
pair.insert("value".to_string(), value.clone());
Value::Object(pair)
})
.collect(),
),
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const TRUEX_OCEL2_VALID: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../examples/out/truex_ocel2_valid.json"
));
#[test]
fn parses_native_shape_directly() {
let native = r#"{
"eventTypes": ["A"],
"objectTypes": ["X"],
"events": [
{"id": "e1", "type": "A", "time": "2026-01-01T00:00:00Z", "attributes": {}}
],
"objects": []
}"#;
let ocel = parse_ocel_tolerant(native).expect("native shape should parse");
assert_eq!(ocel.events.len(), 1);
assert_eq!(ocel.event_types, vec!["A".to_string()]);
}
#[test]
fn parses_truex_receipt_envelope_with_ocel_prefixed_keys() {
let ocel = parse_ocel_tolerant(TRUEX_OCEL2_VALID)
.expect("should recognize the ocel2 envelope shape and extract events");
assert_eq!(
ocel.events.len(),
4,
"expected the real event count from examples/out/truex_ocel2_valid.json"
);
let mut event_types = ocel.event_types.clone();
event_types.sort();
assert_eq!(
event_types,
vec!["Mutation".to_string(), "ReceiptDecision".to_string()]
);
let mut object_types = ocel.object_types.clone();
object_types.sort();
assert_eq!(
object_types,
vec![
"Order".to_string(),
"Receipt".to_string(),
"Session".to_string(),
"User".to_string(),
]
);
assert_eq!(ocel.objects.len(), 3);
assert!(ocel
.objects
.iter()
.any(|o| o.id == "USER_442" && o.object_type == "User"));
assert!(ocel
.events
.iter()
.all(|e| !e.id.is_empty() && e.event_type == "Mutation"));
assert!(ocel
.events
.iter()
.any(|e| e.id == "evt_1779425362745_k0jdn"));
assert!(ocel.events.iter().all(|e| e.object_refs.is_empty()));
}
#[test]
fn errors_on_unrecognizable_input() {
let garbage = r#"{"totally": "unrelated", "shape": true}"#;
let result = parse_ocel_tolerant(garbage);
assert!(
result.is_err(),
"unrecognizable input must error, not silently succeed empty"
);
}
}