use serde::Serialize;
use crate::domain::{AmountEntry, Jurisdiction, Money};
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 id: String,
pub case_id: String,
pub title: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub summary: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub case_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub amounts: Vec<AmountEntry>,
pub nodes: Vec<NodeOutput>,
pub relationships: Vec<RelOutput>,
pub sources: Vec<crate::parser::SourceEntry>,
}
#[derive(Debug, Serialize)]
pub struct NodeOutput {
pub id: String,
pub label: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub slug: Option<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 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 = "Vec::is_empty")]
pub role: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nationality: Option<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 status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub org_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jurisdiction: Option<Jurisdiction>,
#[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 event_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub occurred_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub doc_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issued_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issuing_authority: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub case_number: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub case_type: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub amounts: Vec<AmountEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub asset_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<Money>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<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 = "Vec::is_empty")]
pub amounts: Vec<AmountEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_until: Option<String>,
}
pub struct BuildResult {
pub output: CaseOutput,
pub case_pending: Vec<PendingId>,
pub registry_pending: Vec<(String, PendingId)>,
}
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
clippy::implicit_hasher
)]
pub fn build_output(
case_id: &str,
case_nulid: &str,
title: &str,
summary: &str,
case_tags: &[String],
case_slug: Option<&str>,
case_type: Option<&str>,
case_status: Option<&str>,
case_amounts: Option<&str>,
sources: &[crate::parser::SourceEntry],
related_cases: &[crate::parser::RelatedCase],
case_nulid_map: &std::collections::HashMap<String, (String, String)>,
entities: &[Entity],
rels: &[Rel],
registry_entities: &[Entity],
involved: &[crate::parser::InvolvedEntry],
) -> 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),
}
}
let mut registry_entity_ids: Vec<String> = Vec::new();
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()));
registry_entity_ids.push(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 {
let kind = if r.rel_type == "preceded_by" {
WriteBackKind::TimelineEdge
} else {
WriteBackKind::Relationship
};
case_pending.push(PendingId {
line: r.line,
id: id_str.clone(),
kind,
});
}
relationships.push(rel_to_output(&id_str, &source_id, &target_id, r));
}
Err(err) => errors.push(err),
}
}
if !errors.is_empty() {
return Err(errors);
}
let case_amounts_parsed = match case_amounts {
Some(s) => AmountEntry::parse_dsl(s).unwrap_or_default(),
None => Vec::new(),
};
let case_node = NodeOutput {
id: case_nulid.to_string(),
label: "case".to_string(),
name: title.to_string(),
slug: case_slug.map(String::from),
qualifier: None,
description: if summary.is_empty() {
None
} else {
Some(summary.to_string())
},
thumbnail: None,
aliases: Vec::new(),
urls: Vec::new(),
role: Vec::new(),
nationality: None,
date_of_birth: None,
place_of_birth: None,
status: case_status.map(String::from),
org_type: None,
jurisdiction: None,
headquarters: None,
founded_date: None,
registration_number: None,
event_type: None,
occurred_at: None,
severity: None,
doc_type: None,
issued_at: None,
issuing_authority: None,
case_number: None,
case_type: case_type.map(String::from),
amounts: case_amounts_parsed.clone(),
asset_type: None,
value: None,
tags: case_tags.to_vec(),
};
nodes.push(case_node);
for entity_id in ®istry_entity_ids {
let entity_name = entity_ids
.iter()
.find(|(_, id)| id == entity_id)
.map(|(name, _)| name.as_str())
.unwrap_or_default();
let involved_entry = involved
.iter()
.find(|ie| ie.entity_name == entity_name);
let stored_id = involved_entry.and_then(|ie| ie.id.as_deref());
let entry_line = involved_entry.map_or(0, |ie| ie.line);
match nulid_gen::resolve_id(stored_id, entry_line) {
Ok((id, generated)) => {
let id_str = id.to_string();
if generated {
case_pending.push(PendingId {
line: entry_line,
id: id_str.clone(),
kind: WriteBackKind::InvolvedIn {
entity_name: entity_name.to_string(),
},
});
}
relationships.push(RelOutput {
id: id_str,
rel_type: "involved_in".to_string(),
source_id: entity_id.clone(),
target_id: case_nulid.to_string(),
source_urls: Vec::new(),
description: None,
amounts: Vec::new(),
valid_from: None,
valid_until: None,
});
}
Err(err) => errors.push(err),
}
}
for rc in related_cases {
if let Some((target_nulid, target_title)) = case_nulid_map.get(&rc.case_path) {
match nulid_gen::resolve_id(rc.id.as_deref(), rc.line) {
Ok((id, generated)) => {
let id_str = id.to_string();
if generated {
case_pending.push(PendingId {
line: rc.line,
id: id_str.clone(),
kind: WriteBackKind::RelatedCase,
});
}
relationships.push(RelOutput {
id: id_str,
rel_type: "related_to".to_string(),
source_id: case_nulid.to_string(),
target_id: target_nulid.clone(),
source_urls: Vec::new(),
description: Some(rc.description.clone()),
amounts: Vec::new(),
valid_from: None,
valid_until: None,
});
nodes.push(NodeOutput {
id: target_nulid.clone(),
label: "case".to_string(),
name: target_title.clone(),
slug: Some(format!("cases/{}", rc.case_path)),
qualifier: None,
description: None,
thumbnail: None,
aliases: Vec::new(),
urls: Vec::new(),
role: Vec::new(),
nationality: None,
date_of_birth: None,
place_of_birth: None,
status: None,
org_type: None,
jurisdiction: None,
headquarters: None,
founded_date: None,
registration_number: None,
event_type: None,
occurred_at: None,
severity: None,
doc_type: None,
issued_at: None,
issuing_authority: None,
case_number: None,
case_type: None,
amounts: Vec::new(),
asset_type: None,
value: None,
tags: Vec::new(),
});
}
Err(err) => errors.push(err),
}
} else {
errors.push(ParseError {
line: 0,
message: format!("related case not found in index: {}", rc.case_path),
});
}
}
if !errors.is_empty() {
return Err(errors);
}
Ok(BuildResult {
output: CaseOutput {
id: case_nulid.to_string(),
case_id: case_id.to_string(),
title: title.to_string(),
summary: summary.to_string(),
tags: case_tags.to_vec(),
slug: case_slug.map(String::from),
case_type: case_type.map(String::from),
status: case_status.map(String::from),
amounts: case_amounts_parsed,
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(),
slug: entity.slug.clone(),
qualifier: None,
description: None,
thumbnail: None,
aliases: Vec::new(),
urls: Vec::new(),
role: Vec::new(),
nationality: None,
date_of_birth: None,
place_of_birth: None,
status: None,
org_type: None,
jurisdiction: None,
headquarters: None,
founded_date: None,
registration_number: None,
event_type: None,
occurred_at: None,
severity: None,
doc_type: None,
issued_at: None,
issuing_authority: None,
case_number: None,
case_type: None,
amounts: Vec::new(),
asset_type: None,
value: None,
tags: entity.tags.clone(),
};
for (key, value) in &entity.fields {
match key.as_str() {
"qualifier" => node.qualifier = single(value),
"description" => node.description = single(value),
"thumbnail" | "thumbnail_source" => node.thumbnail = single(value),
"aliases" => node.aliases = list(value),
"urls" => node.urls = list(value),
"role" => node.role = list(value),
"nationality" => node.nationality = single(value),
"date_of_birth" => node.date_of_birth = single(value),
"place_of_birth" => node.place_of_birth = single(value),
"status" => node.status = single(value),
"org_type" => node.org_type = single(value),
"jurisdiction" => {
node.jurisdiction = single(value).and_then(|s| parse_jurisdiction(&s));
}
"headquarters" => node.headquarters = single(value),
"founded_date" => node.founded_date = single(value),
"registration_number" => node.registration_number = single(value),
"event_type" => node.event_type = single(value),
"occurred_at" => node.occurred_at = single(value),
"severity" => node.severity = single(value),
"doc_type" => node.doc_type = single(value),
"issued_at" => node.issued_at = single(value),
"issuing_authority" => node.issuing_authority = single(value),
"case_number" => node.case_number = single(value),
"asset_type" => node.asset_type = single(value),
"value" => node.value = single(value).and_then(|s| parse_money(&s)),
_ => {} }
}
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,
amounts: Vec::new(),
valid_from: None,
valid_until: None,
};
for (key, value) in &rel.fields {
match key.as_str() {
"description" => output.description = Some(value.clone()),
"amounts" => {
output.amounts = AmountEntry::parse_dsl(value).unwrap_or_default();
}
"valid_from" => output.valid_from = Some(value.clone()),
"valid_until" => output.valid_until = 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(),
}
}
fn parse_jurisdiction(s: &str) -> Option<Jurisdiction> {
if s.is_empty() {
return None;
}
if let Some((country, subdivision)) = s.split_once('/') {
Some(Jurisdiction {
country: country.to_string(),
subdivision: Some(subdivision.to_string()),
})
} else {
Some(Jurisdiction {
country: s.to_string(),
subdivision: None,
})
}
}
fn parse_money(s: &str) -> Option<Money> {
let parts: Vec<&str> = s.splitn(3, ' ').collect();
if parts.len() < 3 {
return None;
}
let amount = parts[0].parse::<i64>().ok()?;
let currency = parts[1].to_string();
let display = parts[2]
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(parts[2])
.to_string();
Some(Money {
amount,
currency,
display,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::{FieldValue, Label};
use std::collections::HashMap;
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,
tags: Vec::new(),
slug: None,
}
}
#[test]
fn build_minimal_output() {
let entities = vec![make_entity("Alice", Label::Person, vec![])];
let rels = vec![];
let result = build_output(
"test",
"01TEST00000000000000000000",
"Title",
"Summary",
&[],
None,
None,
None,
None,
&[],
&[],
&HashMap::new(),
&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(), 2); assert_eq!(result.output.nodes[0].name, "Alice");
assert_eq!(result.output.nodes[0].label, "person");
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::Person,
vec![
("qualifier", FieldValue::Single("Kit Manager".into())),
("nationality", FieldValue::Single("GB".into())),
("role", FieldValue::Single("custom:Kit Manager".into())),
(
"aliases",
FieldValue::List(vec!["Marky".into(), "MB".into()]),
),
],
)];
let result = build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
None,
None,
None,
&[],
&[],
&HashMap::new(),
&entities,
&[],
&[],
&[],
)
.unwrap();
let node = &result.output.nodes[0];
assert_eq!(node.qualifier, Some("Kit Manager".into()));
assert_eq!(node.nationality, Some("GB".into()));
assert_eq!(node.role, vec!["custom:Kit Manager"]);
assert_eq!(node.aliases, vec!["Marky", "MB"]);
assert!(node.org_type.is_none());
}
#[test]
fn relationship_output() {
let entities = vec![
make_entity("Alice", Label::Person, vec![]),
make_entity("Corp", Label::Organization, 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![("amounts".into(), "50000 EUR".into())],
id: None,
line: 10,
}];
let result = build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
None,
None,
None,
&[],
&[],
&HashMap::new(),
&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.amounts.len(), 1);
assert_eq!(rel.amounts[0].value, 50_000);
assert_eq!(rel.amounts[0].currency, "EUR");
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::Person, vec![])];
let result = build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
None,
None,
None,
&[],
&[],
&HashMap::new(),
&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::Person,
vec![("nationality", FieldValue::Single("Dutch".into()))],
),
make_entity(
"Corp",
Label::Organization,
vec![("org_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![crate::parser::SourceEntry::Url(
"https://example.com".into(),
)];
let result = build_output(
"test-case",
"01TEST00000000000000000000",
"Test Case",
"A summary.",
&[],
None,
None,
None,
None,
&sources,
&[],
&HashMap::new(),
&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("\"org_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::Person,
fields: vec![],
id: Some("01JABC000000000000000000AA".to_string()),
line: 1,
tags: Vec::new(),
slug: None,
}];
let result = build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
None,
None,
None,
&[],
&[],
&HashMap::new(),
&entities,
&[],
&[],
&[],
)
.unwrap();
assert!(result.case_pending.is_empty());
}
#[test]
fn jurisdiction_structured_output() {
let entities = vec![make_entity(
"KPK",
Label::Organization,
vec![
("org_type", FieldValue::Single("government_agency".into())),
(
"jurisdiction",
FieldValue::Single("ID/South Sulawesi".into()),
),
],
)];
let result = build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
None,
None,
None,
&[],
&[],
&HashMap::new(),
&entities,
&[],
&[],
&[],
)
.unwrap();
let node = &result.output.nodes[0];
let j = node
.jurisdiction
.as_ref()
.expect("jurisdiction should be set");
assert_eq!(j.country, "ID");
assert_eq!(j.subdivision.as_deref(), Some("South Sulawesi"));
let json = serde_json::to_string(&result.output).unwrap_or_default();
assert!(json.contains("\"country\":\"ID\""));
assert!(json.contains("\"subdivision\":\"South Sulawesi\""));
}
#[test]
fn jurisdiction_country_only() {
let entities = vec![make_entity(
"KPK",
Label::Organization,
vec![("jurisdiction", FieldValue::Single("GB".into()))],
)];
let result = build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
None,
None,
None,
&[],
&[],
&HashMap::new(),
&entities,
&[],
&[],
&[],
)
.unwrap();
let j = result.output.nodes[0].jurisdiction.as_ref().unwrap();
assert_eq!(j.country, "GB");
assert!(j.subdivision.is_none());
}
#[test]
fn money_structured_output() {
let entities = vec![make_entity(
"Bribe Fund",
Label::Asset,
vec![(
"value",
FieldValue::Single("500000000000 IDR \"Rp 500 billion\"".into()),
)],
)];
let result = build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
None,
None,
None,
&[],
&[],
&HashMap::new(),
&entities,
&[],
&[],
&[],
)
.unwrap();
let node = &result.output.nodes[0];
let m = node.value.as_ref().expect("value should be set");
assert_eq!(m.amount, 500_000_000_000);
assert_eq!(m.currency, "IDR");
assert_eq!(m.display, "Rp 500 billion");
let json = serde_json::to_string(&result.output).unwrap_or_default();
assert!(json.contains("\"amount\":500000000000"));
assert!(json.contains("\"currency\":\"IDR\""));
assert!(json.contains("\"display\":\"Rp 500 billion\""));
}
#[test]
fn rel_temporal_fields_output() {
let entities = vec![
make_entity("Alice", Label::Person, vec![]),
make_entity("Corp", Label::Organization, vec![]),
];
let rels = vec![Rel {
source_name: "Alice".into(),
target_name: "Corp".into(),
rel_type: "employed_by".into(),
source_urls: vec![],
fields: vec![
("valid_from".into(), "2020-01".into()),
("valid_until".into(), "2024-06".into()),
],
id: None,
line: 1,
}];
let result = build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
None,
None,
None,
&[],
&[],
&HashMap::new(),
&entities,
&rels,
&[],
&[],
)
.unwrap();
let rel = &result.output.relationships[0];
assert_eq!(rel.valid_from.as_deref(), Some("2020-01"));
assert_eq!(rel.valid_until.as_deref(), Some("2024-06"));
let json = serde_json::to_string(&result.output).unwrap_or_default();
assert!(json.contains("\"valid_from\":\"2020-01\""));
assert!(json.contains("\"valid_until\":\"2024-06\""));
assert!(!json.contains("effective_date"));
assert!(!json.contains("expiry_date"));
}
#[test]
fn build_output_with_related_cases() {
use crate::parser::RelatedCase;
let related = vec![RelatedCase {
case_path: "id/corruption/2002/target-case".into(),
description: "Related scandal".into(),
id: None,
line: 0,
}];
let mut case_map = HashMap::new();
case_map.insert(
"id/corruption/2002/target-case".to_string(),
(
"01TARGET0000000000000000000".to_string(),
"Target Case Title".to_string(),
),
);
let result = build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
None,
None,
None,
&[],
&related,
&case_map,
&[],
&[],
&[],
&[],
)
.unwrap();
let rel = result
.output
.relationships
.iter()
.find(|r| r.rel_type == "related_to")
.expect("expected a related_to relationship");
assert_eq!(rel.target_id, "01TARGET0000000000000000000");
assert_eq!(rel.description, Some("Related scandal".into()));
let target_node = result
.output
.nodes
.iter()
.find(|n| n.id == "01TARGET0000000000000000000" && n.label == "case")
.expect("expected a target case node");
assert_eq!(target_node.label, "case");
}
#[test]
fn build_output_related_case_not_in_index() {
use crate::parser::RelatedCase;
let related = vec![RelatedCase {
case_path: "id/corruption/2002/nonexistent-case".into(),
description: "Does not exist".into(),
id: None,
line: 0,
}];
let errs = match build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
None,
None,
None,
&[],
&related,
&HashMap::new(),
&[],
&[],
&[],
&[],
) {
Err(e) => e,
Ok(_) => panic!("expected error for missing related case"),
};
assert!(
errs.iter()
.any(|e| e.message.contains("not found in index"))
);
}
}