use serde::Serialize;
use crate::entity::{Entity, FieldValue};
use crate::nulid_gen;
use crate::parser::ParseError;
use crate::relationship::Rel;
use crate::writeback::{PendingId, WriteBackKind};
#[derive(Debug, Serialize)]
pub struct CaseOutput {
pub case_id: String,
pub title: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub summary: String,
pub nodes: Vec<NodeOutput>,
pub relationships: Vec<RelOutput>,
pub sources: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct NodeOutput {
pub id: String,
pub label: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub qualifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub occurred_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub urls: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date_of_birth: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub place_of_birth: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nationality: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub occupation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub institution_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jurisdiction: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub headquarters: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub founded_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_number: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub document_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub case_number: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filing_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issuing_authority: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct RelOutput {
pub id: String,
#[serde(rename = "type")]
pub rel_type: String,
pub source_id: String,
pub target_id: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub source_urls: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effective_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiry_date: Option<String>,
}
pub struct BuildResult {
pub output: CaseOutput,
pub case_pending: Vec<PendingId>,
pub registry_pending: Vec<(String, PendingId)>,
}
pub fn build_output(
case_id: &str,
title: &str,
summary: &str,
sources: &[String],
entities: &[Entity],
rels: &[Rel],
registry_entities: &[Entity],
) -> Result<BuildResult, Vec<ParseError>> {
let mut errors = Vec::new();
let mut case_pending = Vec::new();
let mut registry_pending = Vec::new();
let mut entity_ids: Vec<(String, String)> = Vec::new();
let mut nodes = Vec::new();
for e in entities {
match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
Ok((id, generated)) => {
let id_str = id.to_string();
if generated {
case_pending.push(PendingId {
line: e.line,
id: id_str.clone(),
kind: WriteBackKind::InlineEvent,
});
}
entity_ids.push((e.name.clone(), id_str.clone()));
nodes.push(entity_to_node(&id_str, e));
}
Err(err) => errors.push(err),
}
}
for e in registry_entities {
match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
Ok((id, generated)) => {
let id_str = id.to_string();
if generated {
registry_pending.push((
e.name.clone(),
PendingId {
line: e.line,
id: id_str.clone(),
kind: WriteBackKind::EntityFrontMatter,
},
));
}
entity_ids.push((e.name.clone(), id_str.clone()));
nodes.push(entity_to_node(&id_str, e));
}
Err(err) => errors.push(err),
}
}
let mut relationships = Vec::new();
for r in rels {
let source_id = entity_ids
.iter()
.find(|(name, _)| name == &r.source_name)
.map(|(_, id)| id.clone())
.unwrap_or_default();
let target_id = entity_ids
.iter()
.find(|(name, _)| name == &r.target_name)
.map(|(_, id)| id.clone())
.unwrap_or_default();
match nulid_gen::resolve_id(r.id.as_deref(), r.line) {
Ok((id, generated)) => {
let id_str = id.to_string();
if generated && r.rel_type != "next" {
case_pending.push(PendingId {
line: r.line,
id: id_str.clone(),
kind: WriteBackKind::Relationship,
});
}
relationships.push(rel_to_output(&id_str, &source_id, &target_id, r));
}
Err(err) => errors.push(err),
}
}
if !errors.is_empty() {
return Err(errors);
}
Ok(BuildResult {
output: CaseOutput {
case_id: case_id.to_string(),
title: title.to_string(),
summary: summary.to_string(),
nodes,
relationships,
sources: sources.to_vec(),
},
case_pending,
registry_pending,
})
}
fn entity_to_node(id: &str, entity: &Entity) -> NodeOutput {
let label = entity.label.to_string();
let mut node = NodeOutput {
id: id.to_string(),
label,
name: entity.name.clone(),
qualifier: None,
description: None,
occurred_at: None,
thumbnail: None,
aliases: Vec::new(),
urls: Vec::new(),
date_of_birth: None,
place_of_birth: None,
nationality: None,
occupation: None,
institution_type: None,
jurisdiction: None,
headquarters: None,
founded_date: None,
registration_number: None,
document_type: None,
case_number: None,
filing_date: None,
issuing_authority: None,
};
for (key, value) in &entity.fields {
match key.as_str() {
"qualifier" => node.qualifier = single(value),
"description" => node.description = single(value),
"occurred_at" => node.occurred_at = single(value),
"thumbnail" | "thumbnail_source" => node.thumbnail = single(value),
"aliases" => node.aliases = list(value),
"urls" => node.urls = list(value),
"date_of_birth" => node.date_of_birth = single(value),
"place_of_birth" => node.place_of_birth = single(value),
"nationality" => node.nationality = single(value),
"occupation" => node.occupation = single(value),
"institution_type" => node.institution_type = single(value),
"jurisdiction" => node.jurisdiction = single(value),
"headquarters" => node.headquarters = single(value),
"founded_date" => node.founded_date = single(value),
"registration_number" => node.registration_number = single(value),
"document_type" => node.document_type = single(value),
"case_number" => node.case_number = single(value),
"filing_date" => node.filing_date = single(value),
"issuing_authority" => node.issuing_authority = single(value),
_ => {} }
}
node
}
fn rel_to_output(id: &str, source_id: &str, target_id: &str, rel: &Rel) -> RelOutput {
let mut output = RelOutput {
id: id.to_string(),
rel_type: rel.rel_type.clone(),
source_id: source_id.to_string(),
target_id: target_id.to_string(),
source_urls: rel.source_urls.clone(),
description: None,
amount: None,
currency: None,
effective_date: None,
expiry_date: None,
};
for (key, value) in &rel.fields {
match key.as_str() {
"description" => output.description = Some(value.clone()),
"amount" => output.amount = Some(value.clone()),
"currency" => output.currency = Some(value.clone()),
"effective_date" => output.effective_date = Some(value.clone()),
"expiry_date" => output.expiry_date = Some(value.clone()),
_ => {}
}
}
output
}
fn single(value: &FieldValue) -> Option<String> {
match value {
FieldValue::Single(s) if !s.is_empty() => Some(s.clone()),
_ => None,
}
}
fn list(value: &FieldValue) -> Vec<String> {
match value {
FieldValue::List(items) => items.clone(),
FieldValue::Single(s) if !s.is_empty() => vec![s.clone()],
FieldValue::Single(_) => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::{FieldValue, Label};
fn make_entity(name: &str, label: Label, fields: Vec<(&str, FieldValue)>) -> Entity {
Entity {
name: name.to_string(),
label,
fields: fields
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect(),
id: None,
line: 1,
}
}
#[test]
fn build_minimal_output() {
let entities = vec![make_entity("Alice", Label::Actor, vec![])];
let rels = vec![];
let result = build_output("test", "Title", "Summary", &[], &entities, &rels, &[]).unwrap();
assert_eq!(result.output.case_id, "test");
assert_eq!(result.output.title, "Title");
assert_eq!(result.output.summary, "Summary");
assert_eq!(result.output.nodes.len(), 1);
assert_eq!(result.output.nodes[0].name, "Alice");
assert_eq!(result.output.nodes[0].label, "actor");
assert!(!result.output.nodes[0].id.is_empty());
assert_eq!(result.case_pending.len(), 1);
}
#[test]
fn node_fields_populated() {
let entities = vec![make_entity(
"Mark",
Label::Actor,
vec![
("qualifier", FieldValue::Single("Kit Manager".into())),
("nationality", FieldValue::Single("British".into())),
(
"occupation",
FieldValue::Single("custom:Kit Manager".into()),
),
(
"aliases",
FieldValue::List(vec!["Marky".into(), "MB".into()]),
),
],
)];
let result = build_output("case", "T", "", &[], &entities, &[], &[]).unwrap();
let node = &result.output.nodes[0];
assert_eq!(node.qualifier, Some("Kit Manager".into()));
assert_eq!(node.nationality, Some("British".into()));
assert_eq!(node.occupation, Some("custom:Kit Manager".into()));
assert_eq!(node.aliases, vec!["Marky", "MB"]);
assert!(node.institution_type.is_none());
}
#[test]
fn relationship_output() {
let entities = vec![
make_entity("Alice", Label::Actor, vec![]),
make_entity("Corp", Label::Institution, vec![]),
];
let rels = vec![Rel {
source_name: "Alice".into(),
target_name: "Corp".into(),
rel_type: "employed_by".into(),
source_urls: vec!["https://example.com".into()],
fields: vec![("amount".into(), "EUR 50,000".into())],
id: None,
line: 10,
}];
let result = build_output("case", "T", "", &[], &entities, &rels, &[]).unwrap();
assert_eq!(result.output.relationships.len(), 1);
let rel = &result.output.relationships[0];
assert_eq!(rel.rel_type, "employed_by");
assert_eq!(rel.source_urls, vec!["https://example.com"]);
assert_eq!(rel.amount, Some("EUR 50,000".into()));
assert_eq!(rel.source_id, result.output.nodes[0].id);
assert_eq!(rel.target_id, result.output.nodes[1].id);
assert!(
result
.case_pending
.iter()
.any(|p| matches!(p.kind, WriteBackKind::Relationship))
);
}
#[test]
fn empty_optional_fields_omitted_in_json() {
let entities = vec![make_entity("Test", Label::Actor, vec![])];
let result = build_output("case", "T", "", &[], &entities, &[], &[]).unwrap();
let json = serde_json::to_string(&result.output).unwrap_or_default();
assert!(!json.contains("qualifier"));
assert!(!json.contains("description"));
assert!(!json.contains("aliases"));
assert!(!json.contains("summary"));
}
#[test]
fn json_roundtrip() {
let entities = vec![
make_entity(
"Alice",
Label::Actor,
vec![("nationality", FieldValue::Single("Dutch".into()))],
),
make_entity(
"Corp",
Label::Institution,
vec![("institution_type", FieldValue::Single("corporation".into()))],
),
];
let rels = vec![Rel {
source_name: "Alice".into(),
target_name: "Corp".into(),
rel_type: "employed_by".into(),
source_urls: vec!["https://example.com".into()],
fields: vec![],
id: None,
line: 1,
}];
let sources = vec!["https://example.com".into()];
let result = build_output(
"test-case",
"Test Case",
"A summary.",
&sources,
&entities,
&rels,
&[],
)
.unwrap();
let json = serde_json::to_string_pretty(&result.output).unwrap_or_default();
assert!(json.contains("\"case_id\": \"test-case\""));
assert!(json.contains("\"nationality\": \"Dutch\""));
assert!(json.contains("\"institution_type\": \"corporation\""));
assert!(json.contains("\"type\": \"employed_by\""));
}
#[test]
fn no_pending_when_ids_present() {
let entities = vec![Entity {
name: "Alice".to_string(),
label: Label::Actor,
fields: vec![],
id: Some("01JABC000000000000000000AA".to_string()),
line: 1,
}];
let result = build_output("case", "T", "", &[], &entities, &[], &[]).unwrap();
assert!(result.case_pending.is_empty());
}
}