Packages
rbt_weave
0.2.65
0.2.67
0.2.66
0.2.65
0.2.64
0.2.63
0.2.62
0.2.61
0.2.60
0.2.59
0.2.58
0.2.57
0.2.56
0.2.55
0.2.54
0.2.53
0.2.52
0.2.51
0.2.50
0.2.49
0.2.48
0.2.47
0.2.46
0.2.45
0.2.44
0.2.43
0.2.42
0.2.41
0.2.40
0.2.39
0.2.38
0.2.37
0.2.36
0.2.35
0.2.34
0.2.33
0.2.32
0.2.31
0.2.30
0.2.29
0.2.28
0.2.27
0.2.26
0.2.25
0.2.24
0.2.23
0.2.22
0.2.21
0.2.20
0.2.19
0.2.18
0.2.17
0.2.16
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Graph conflict detection and image processing NIFs for OSINT knowledge graphs, powered by Rust.
Current section
Files
Jump to
Current section
Files
crates/weave-content/src/output.rs
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};
/// JSON output for a complete case file.
#[derive(Debug, Serialize)]
pub struct CaseOutput {
/// NULID for the case node.
pub id: String,
pub case_id: String,
pub title: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tagline: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// File-path slug for the case (e.g. `cases/id/corruption/2024/hambalang-case`).
#[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>,
}
/// JSON output for a single node (entity).
#[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>,
// Person fields
#[serde(skip_serializing_if = "Vec::is_empty")]
pub role: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub nationality: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date_of_birth: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date_of_death: 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>,
// Organization fields
#[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>,
// Event fields
#[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>,
// Document fields
#[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>,
// Case fields
#[serde(skip_serializing_if = "Option::is_none")]
pub case_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tagline: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub amounts: Vec<AmountEntry>,
// Asset fields
#[serde(skip_serializing_if = "Option::is_none")]
pub asset_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<Money>,
// Tags
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
/// JSON output for a single relationship.
#[derive(Debug, Serialize)]
pub struct RelOutput {
pub id: String,
#[serde(rename = "type")]
pub rel_type: String,
pub source_id: String,
pub source_label: String,
pub target_id: String,
pub target_label: 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>,
}
/// Result of building output: the JSON structure plus any pending write-backs.
pub struct BuildResult {
pub output: CaseOutput,
/// IDs generated for inline entities and relationships in case files.
pub case_pending: Vec<PendingId>,
/// IDs generated for registry entities (keyed by entity name for
/// the caller to route to the correct entity file).
pub registry_pending: Vec<(String, PendingId)>,
}
/// Build the JSON output from parsed entities and relationships.
///
/// Resolves stored IDs or generates new ones. Returns errors if any ID is invalid.
/// `registry_entities` are shared entities referenced by relationships -- they are
/// included in the output nodes alongside inline entities.
///
/// The case itself is emitted as a `Case` node, and `INVOLVED_IN` relationships
/// are auto-generated for every entity in the case.
#[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>,
case_tagline: 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();
// Build entity name -> ID mapping
let mut entity_ids: Vec<(String, String)> = Vec::new();
let mut nodes = Vec::new();
// Process inline entities (events from case file)
let mut inline_entity_ids: Vec<String> = 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()));
inline_entity_ids.push(id_str.clone());
nodes.push(entity_to_node(&id_str, e));
}
Err(err) => errors.push(err),
}
}
// Process referenced registry entities (shared people/organizations)
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 = entity_ids
.iter()
.find(|(name, _)| name == &r.source_name)
.map(|(_, id)| id.clone())
.unwrap_or_default();
let target = entity_ids
.iter()
.find(|(name, _)| name == &r.target_name)
.map(|(_, id)| id.clone())
.unwrap_or_default();
let source_label = nodes
.iter()
.find(|n| n.id == source)
.map_or("event", |n| n.label.as_str());
let target_label = nodes
.iter()
.find(|n| n.id == target)
.map_or("event", |n| n.label.as_str());
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,
source_label,
&target,
target_label,
r,
));
}
Err(err) => errors.push(err),
}
}
if !errors.is_empty() {
return Err(errors);
}
// Parse case amounts from DSL string
let case_amounts_parsed = match case_amounts {
Some(s) => AmountEntry::parse_dsl(s).unwrap_or_default(),
None => Vec::new(),
};
// Build Case node
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: Vec::new(),
date_of_birth: None,
date_of_death: 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),
tagline: case_tagline.map(String::from),
amounts: case_amounts_parsed.clone(),
asset_type: None,
value: None,
tags: case_tags.to_vec(),
};
nodes.push(case_node);
// Auto-generate INVOLVED_IN relationships: (entity)-[:INVOLVED_IN]->(case)
// For all entities: shared (people/organizations) and inline (events).
// Uses IDs from ## Involved section if present; generates and writes back if missing.
let all_entity_ids: Vec<&str> = registry_entity_ids
.iter()
.chain(inline_entity_ids.iter())
.map(String::as_str)
.collect();
for entity_id in &all_entity_ids {
// Find entity name from entity_ids mapping
let entity_name = entity_ids
.iter()
.find(|(_, id)| id.as_str() == *entity_id)
.map(|(name, _)| name.as_str())
.unwrap_or_default();
// Look up existing ID from ## Involved section
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(),
},
});
}
let source_label = nodes
.iter()
.find(|n| n.id == *entity_id)
.map_or("person", |n| n.label.as_str());
relationships.push(RelOutput {
id: id_str,
rel_type: "involved_in".to_string(),
source_id: (*entity_id).to_string(),
source_label: source_label.to_string(),
target_id: case_nulid.to_string(),
target_label: "case".to_string(),
source_urls: Vec::new(),
description: None,
amounts: Vec::new(),
valid_from: None,
valid_until: None,
});
}
Err(err) => errors.push(err),
}
}
// Generate related_to relationships from ## Related Cases
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(),
source_label: "case".to_string(),
target_id: target_nulid.clone(),
target_label: "case".to_string(),
source_urls: Vec::new(),
description: Some(rc.description.clone()),
amounts: Vec::new(),
valid_from: None,
valid_until: None,
});
// Add minimal target case node so HTML renderer can link to it
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: Vec::new(),
date_of_birth: None,
date_of_death: 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,
tagline: 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(),
tagline: case_tagline.map(String::from),
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: Vec::new(),
date_of_birth: None,
date_of_death: 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,
tagline: 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 = dedup(list(value)),
"role" => node.role = list(value),
"nationality" => node.nationality = list(value),
"date_of_birth" => node.date_of_birth = single(value),
"date_of_death" => node.date_of_death = single(value),
"place_of_birth" => node.place_of_birth = single(value),
"status" => node.status = single(value),
"org_type" => node.org_type = single(value),
"event_type" => node.event_type = single(value),
"doc_type" => node.doc_type = single(value),
"asset_type" => node.asset_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),
"occurred_at" => node.occurred_at = single(value),
"severity" => node.severity = single(value),
"issued_at" => node.issued_at = single(value),
"issuing_authority" => node.issuing_authority = single(value),
"case_number" => node.case_number = single(value),
"value" => node.value = single(value).and_then(|s| parse_money(&s)),
_ => {} // Unknown fields already rejected by validator
}
}
node
}
fn rel_to_output(
id: &str,
source_id: &str,
source_label: &str,
target_id: &str,
target_label: &str,
rel: &Rel,
) -> RelOutput {
let mut output = RelOutput {
id: id.to_string(),
rel_type: rel.rel_type.clone(),
source_id: source_id.to_string(),
source_label: source_label.to_string(),
target_id: target_id.to_string(),
target_label: target_label.to_string(),
source_urls: dedup(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(),
}
}
/// Remove duplicate strings while preserving insertion order.
fn dedup(mut items: Vec<String>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
items.retain(|item| seen.insert(item.clone()));
items
}
/// Parse a validated jurisdiction string (`"ID"` or `"ID/West Java"`) into a
/// structured [`Jurisdiction`].
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,
})
}
}
/// Parse a validated money string (`"500000000000 IDR \"Rp 500 billion\""`)
/// into a structured [`Money`].
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();
// Strip surrounding quotes from display
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,
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); // Alice + Case node
assert_eq!(result.output.nodes[0].name, "Alice");
assert_eq!(result.output.nodes[0].label, "person");
assert!(!result.output.nodes[0].id.is_empty());
// Entity had no id, so one should be generated + INVOLVED_IN id
assert_eq!(result.case_pending.len(), 2);
}
#[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,
None,
&[],
&[],
&HashMap::new(),
&entities,
&[],
&[],
&[],
)
.unwrap();
let node = &result.output.nodes[0];
assert_eq!(node.qualifier, Some("Kit Manager".into()));
assert_eq!(node.nationality, vec!["GB"]);
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,
None,
&[],
&[],
&HashMap::new(),
&entities,
&rels,
&[],
&[],
)
.unwrap();
assert_eq!(result.output.relationships.len(), 3); // 1 explicit + 2 INVOLVED_IN for inline entities
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);
// Relationship had no id, so one should be generated
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,
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::List(vec!["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 = [crate::parser::SourceEntry::Url(
"https://example.com".into(),
)];
let result = build_output(
"test-case",
"01TEST00000000000000000000",
"Test Case",
"A summary.",
&[],
None,
None,
None,
None,
None,
&[],
&[],
&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\": [\n") && json.contains("\"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 involved = vec![crate::parser::InvolvedEntry {
entity_name: "Alice".to_string(),
id: Some("01JABC000000000000000000BB".to_string()),
line: 10,
}];
let result = build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
None,
None,
None,
None,
&[],
&[],
&HashMap::new(),
&entities,
&[],
&[],
&involved,
)
.unwrap();
assert!(result.case_pending.is_empty());
}
#[test]
fn jurisdiction_structured_output() {
let entities = vec![make_entity(
"KPK",
Label::Organization,
vec![
("type", FieldValue::Single("government_agency".into())),
(
"jurisdiction",
FieldValue::Single("ID/South Sulawesi".into()),
),
],
)];
let result = build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
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,
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,
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,
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,
None,
&[],
&related,
&case_map,
&[],
&[],
&[],
&[],
)
.unwrap();
// Should have a related_to relationship
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()));
// Should have a target case node
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 Err(errs) = build_output(
"case",
"01TEST00000000000000000000",
"T",
"",
&[],
None,
None,
None,
None,
None,
&[],
&related,
&HashMap::new(),
&[],
&[],
&[],
&[],
) else {
panic!("expected error for missing related case")
};
assert!(
errs.iter()
.any(|e| e.message.contains("not found in index"))
);
}
}