Skip to main content

weave_content/
output.rs

1use serde::Serialize;
2
3use crate::entity::{Entity, FieldValue};
4use crate::nulid_gen;
5use crate::parser::ParseError;
6use crate::relationship::Rel;
7use crate::writeback::{PendingId, WriteBackKind};
8
9/// JSON output for a complete case file.
10#[derive(Debug, Serialize)]
11pub struct CaseOutput {
12    pub case_id: String,
13    pub title: String,
14    #[serde(skip_serializing_if = "String::is_empty")]
15    pub summary: String,
16    pub nodes: Vec<NodeOutput>,
17    pub relationships: Vec<RelOutput>,
18    pub sources: Vec<String>,
19}
20
21/// JSON output for a single node (entity).
22#[derive(Debug, Serialize)]
23pub struct NodeOutput {
24    pub id: String,
25    pub label: String,
26    pub name: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub qualifier: Option<String>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub description: Option<String>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub occurred_at: Option<String>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub thumbnail: Option<String>,
35    #[serde(skip_serializing_if = "Vec::is_empty")]
36    pub aliases: Vec<String>,
37    #[serde(skip_serializing_if = "Vec::is_empty")]
38    pub urls: Vec<String>,
39    // Actor fields
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub date_of_birth: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub place_of_birth: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub nationality: Option<String>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub occupation: Option<String>,
48    // Institution fields
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub institution_type: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub jurisdiction: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub headquarters: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub founded_date: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub registration_number: Option<String>,
59    // PublicRecord fields
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub document_type: Option<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub case_number: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub filing_date: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub issuing_authority: Option<String>,
68}
69
70/// JSON output for a single relationship.
71#[derive(Debug, Serialize)]
72pub struct RelOutput {
73    pub id: String,
74    #[serde(rename = "type")]
75    pub rel_type: String,
76    pub source_id: String,
77    pub target_id: String,
78    #[serde(skip_serializing_if = "Vec::is_empty")]
79    pub source_urls: Vec<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub description: Option<String>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub amount: Option<String>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub currency: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub effective_date: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub expiry_date: Option<String>,
90}
91
92/// Result of building output: the JSON structure plus any pending write-backs.
93pub struct BuildResult {
94    pub output: CaseOutput,
95    /// IDs generated for inline entities and relationships in case files.
96    pub case_pending: Vec<PendingId>,
97    /// IDs generated for registry entities (keyed by entity name for
98    /// the caller to route to the correct entity file).
99    pub registry_pending: Vec<(String, PendingId)>,
100}
101
102/// Build the JSON output from parsed entities and relationships.
103///
104/// Resolves stored IDs or generates new ones. Returns errors if any ID is invalid.
105/// `registry_entities` are shared entities referenced by relationships -- they are
106/// included in the output nodes alongside inline entities.
107pub fn build_output(
108    case_id: &str,
109    title: &str,
110    summary: &str,
111    sources: &[String],
112    entities: &[Entity],
113    rels: &[Rel],
114    registry_entities: &[Entity],
115) -> Result<BuildResult, Vec<ParseError>> {
116    let mut errors = Vec::new();
117    let mut case_pending = Vec::new();
118    let mut registry_pending = Vec::new();
119
120    // Build entity name -> ID mapping
121    let mut entity_ids: Vec<(String, String)> = Vec::new();
122    let mut nodes = Vec::new();
123
124    // Process inline entities (events from case file)
125    for e in entities {
126        match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
127            Ok((id, generated)) => {
128                let id_str = id.to_string();
129                if generated {
130                    case_pending.push(PendingId {
131                        line: e.line,
132                        id: id_str.clone(),
133                        kind: WriteBackKind::InlineEvent,
134                    });
135                }
136                entity_ids.push((e.name.clone(), id_str.clone()));
137                nodes.push(entity_to_node(&id_str, e));
138            }
139            Err(err) => errors.push(err),
140        }
141    }
142
143    // Process referenced registry entities (shared actors/institutions)
144    for e in registry_entities {
145        match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
146            Ok((id, generated)) => {
147                let id_str = id.to_string();
148                if generated {
149                    registry_pending.push((
150                        e.name.clone(),
151                        PendingId {
152                            line: e.line,
153                            id: id_str.clone(),
154                            kind: WriteBackKind::EntityFrontMatter,
155                        },
156                    ));
157                }
158                entity_ids.push((e.name.clone(), id_str.clone()));
159                nodes.push(entity_to_node(&id_str, e));
160            }
161            Err(err) => errors.push(err),
162        }
163    }
164
165    let mut relationships = Vec::new();
166    for r in rels {
167        let source_id = entity_ids
168            .iter()
169            .find(|(name, _)| name == &r.source_name)
170            .map(|(_, id)| id.clone())
171            .unwrap_or_default();
172        let target_id = entity_ids
173            .iter()
174            .find(|(name, _)| name == &r.target_name)
175            .map(|(_, id)| id.clone())
176            .unwrap_or_default();
177
178        match nulid_gen::resolve_id(r.id.as_deref(), r.line) {
179            Ok((id, generated)) => {
180                let id_str = id.to_string();
181                // Skip write-back for NEXT relationships -- they are generated
182                // from timeline chain syntax and have no bullet to attach an ID to.
183                if generated && r.rel_type != "next" {
184                    case_pending.push(PendingId {
185                        line: r.line,
186                        id: id_str.clone(),
187                        kind: WriteBackKind::Relationship,
188                    });
189                }
190                relationships.push(rel_to_output(&id_str, &source_id, &target_id, r));
191            }
192            Err(err) => errors.push(err),
193        }
194    }
195
196    if !errors.is_empty() {
197        return Err(errors);
198    }
199
200    Ok(BuildResult {
201        output: CaseOutput {
202            case_id: case_id.to_string(),
203            title: title.to_string(),
204            summary: summary.to_string(),
205            nodes,
206            relationships,
207            sources: sources.to_vec(),
208        },
209        case_pending,
210        registry_pending,
211    })
212}
213
214fn entity_to_node(id: &str, entity: &Entity) -> NodeOutput {
215    let label = entity.label.to_string();
216
217    let mut node = NodeOutput {
218        id: id.to_string(),
219        label,
220        name: entity.name.clone(),
221        qualifier: None,
222        description: None,
223        occurred_at: None,
224        thumbnail: None,
225        aliases: Vec::new(),
226        urls: Vec::new(),
227        date_of_birth: None,
228        place_of_birth: None,
229        nationality: None,
230        occupation: None,
231        institution_type: None,
232        jurisdiction: None,
233        headquarters: None,
234        founded_date: None,
235        registration_number: None,
236        document_type: None,
237        case_number: None,
238        filing_date: None,
239        issuing_authority: None,
240    };
241
242    for (key, value) in &entity.fields {
243        match key.as_str() {
244            "qualifier" => node.qualifier = single(value),
245            "description" => node.description = single(value),
246            "occurred_at" => node.occurred_at = single(value),
247            "thumbnail" | "thumbnail_source" => node.thumbnail = single(value),
248            "aliases" => node.aliases = list(value),
249            "urls" => node.urls = list(value),
250            "date_of_birth" => node.date_of_birth = single(value),
251            "place_of_birth" => node.place_of_birth = single(value),
252            "nationality" => node.nationality = single(value),
253            "occupation" => node.occupation = single(value),
254            "institution_type" => node.institution_type = single(value),
255            "jurisdiction" => node.jurisdiction = single(value),
256            "headquarters" => node.headquarters = single(value),
257            "founded_date" => node.founded_date = single(value),
258            "registration_number" => node.registration_number = single(value),
259            "document_type" => node.document_type = single(value),
260            "case_number" => node.case_number = single(value),
261            "filing_date" => node.filing_date = single(value),
262            "issuing_authority" => node.issuing_authority = single(value),
263            _ => {} // Unknown fields already rejected by validator
264        }
265    }
266
267    node
268}
269
270fn rel_to_output(id: &str, source_id: &str, target_id: &str, rel: &Rel) -> RelOutput {
271    let mut output = RelOutput {
272        id: id.to_string(),
273        rel_type: rel.rel_type.clone(),
274        source_id: source_id.to_string(),
275        target_id: target_id.to_string(),
276        source_urls: rel.source_urls.clone(),
277        description: None,
278        amount: None,
279        currency: None,
280        effective_date: None,
281        expiry_date: None,
282    };
283
284    for (key, value) in &rel.fields {
285        match key.as_str() {
286            "description" => output.description = Some(value.clone()),
287            "amount" => output.amount = Some(value.clone()),
288            "currency" => output.currency = Some(value.clone()),
289            "effective_date" => output.effective_date = Some(value.clone()),
290            "expiry_date" => output.expiry_date = Some(value.clone()),
291            _ => {}
292        }
293    }
294
295    output
296}
297
298fn single(value: &FieldValue) -> Option<String> {
299    match value {
300        FieldValue::Single(s) if !s.is_empty() => Some(s.clone()),
301        _ => None,
302    }
303}
304
305fn list(value: &FieldValue) -> Vec<String> {
306    match value {
307        FieldValue::List(items) => items.clone(),
308        FieldValue::Single(s) if !s.is_empty() => vec![s.clone()],
309        FieldValue::Single(_) => Vec::new(),
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::entity::{FieldValue, Label};
317
318    fn make_entity(name: &str, label: Label, fields: Vec<(&str, FieldValue)>) -> Entity {
319        Entity {
320            name: name.to_string(),
321            label,
322            fields: fields
323                .into_iter()
324                .map(|(k, v)| (k.to_string(), v))
325                .collect(),
326            id: None,
327            line: 1,
328        }
329    }
330
331    #[test]
332    fn build_minimal_output() {
333        let entities = vec![make_entity("Alice", Label::Actor, vec![])];
334        let rels = vec![];
335        let result = build_output("test", "Title", "Summary", &[], &entities, &rels, &[]).unwrap();
336
337        assert_eq!(result.output.case_id, "test");
338        assert_eq!(result.output.title, "Title");
339        assert_eq!(result.output.summary, "Summary");
340        assert_eq!(result.output.nodes.len(), 1);
341        assert_eq!(result.output.nodes[0].name, "Alice");
342        assert_eq!(result.output.nodes[0].label, "actor");
343        assert!(!result.output.nodes[0].id.is_empty());
344        // Entity had no id, so one should be generated
345        assert_eq!(result.case_pending.len(), 1);
346    }
347
348    #[test]
349    fn node_fields_populated() {
350        let entities = vec![make_entity(
351            "Mark",
352            Label::Actor,
353            vec![
354                ("qualifier", FieldValue::Single("Kit Manager".into())),
355                ("nationality", FieldValue::Single("British".into())),
356                (
357                    "occupation",
358                    FieldValue::Single("custom:Kit Manager".into()),
359                ),
360                (
361                    "aliases",
362                    FieldValue::List(vec!["Marky".into(), "MB".into()]),
363                ),
364            ],
365        )];
366        let result = build_output("case", "T", "", &[], &entities, &[], &[]).unwrap();
367        let node = &result.output.nodes[0];
368
369        assert_eq!(node.qualifier, Some("Kit Manager".into()));
370        assert_eq!(node.nationality, Some("British".into()));
371        assert_eq!(node.occupation, Some("custom:Kit Manager".into()));
372        assert_eq!(node.aliases, vec!["Marky", "MB"]);
373        assert!(node.institution_type.is_none());
374    }
375
376    #[test]
377    fn relationship_output() {
378        let entities = vec![
379            make_entity("Alice", Label::Actor, vec![]),
380            make_entity("Corp", Label::Institution, vec![]),
381        ];
382        let rels = vec![Rel {
383            source_name: "Alice".into(),
384            target_name: "Corp".into(),
385            rel_type: "employed_by".into(),
386            source_urls: vec!["https://example.com".into()],
387            fields: vec![("amount".into(), "EUR 50,000".into())],
388            id: None,
389            line: 10,
390        }];
391
392        let result = build_output("case", "T", "", &[], &entities, &rels, &[]).unwrap();
393        assert_eq!(result.output.relationships.len(), 1);
394
395        let rel = &result.output.relationships[0];
396        assert_eq!(rel.rel_type, "employed_by");
397        assert_eq!(rel.source_urls, vec!["https://example.com"]);
398        assert_eq!(rel.amount, Some("EUR 50,000".into()));
399        assert_eq!(rel.source_id, result.output.nodes[0].id);
400        assert_eq!(rel.target_id, result.output.nodes[1].id);
401        // Relationship had no id, so one should be generated
402        assert!(
403            result
404                .case_pending
405                .iter()
406                .any(|p| matches!(p.kind, WriteBackKind::Relationship))
407        );
408    }
409
410    #[test]
411    fn empty_optional_fields_omitted_in_json() {
412        let entities = vec![make_entity("Test", Label::Actor, vec![])];
413        let result = build_output("case", "T", "", &[], &entities, &[], &[]).unwrap();
414        let json = serde_json::to_string(&result.output).unwrap_or_default();
415
416        assert!(!json.contains("qualifier"));
417        assert!(!json.contains("description"));
418        assert!(!json.contains("aliases"));
419        assert!(!json.contains("summary"));
420    }
421
422    #[test]
423    fn json_roundtrip() {
424        let entities = vec![
425            make_entity(
426                "Alice",
427                Label::Actor,
428                vec![("nationality", FieldValue::Single("Dutch".into()))],
429            ),
430            make_entity(
431                "Corp",
432                Label::Institution,
433                vec![("institution_type", FieldValue::Single("corporation".into()))],
434            ),
435        ];
436        let rels = vec![Rel {
437            source_name: "Alice".into(),
438            target_name: "Corp".into(),
439            rel_type: "employed_by".into(),
440            source_urls: vec!["https://example.com".into()],
441            fields: vec![],
442            id: None,
443            line: 1,
444        }];
445        let sources = vec!["https://example.com".into()];
446
447        let result = build_output(
448            "test-case",
449            "Test Case",
450            "A summary.",
451            &sources,
452            &entities,
453            &rels,
454            &[],
455        )
456        .unwrap();
457        let json = serde_json::to_string_pretty(&result.output).unwrap_or_default();
458
459        assert!(json.contains("\"case_id\": \"test-case\""));
460        assert!(json.contains("\"nationality\": \"Dutch\""));
461        assert!(json.contains("\"institution_type\": \"corporation\""));
462        assert!(json.contains("\"type\": \"employed_by\""));
463    }
464
465    #[test]
466    fn no_pending_when_ids_present() {
467        let entities = vec![Entity {
468            name: "Alice".to_string(),
469            label: Label::Actor,
470            fields: vec![],
471            id: Some("01JABC000000000000000000AA".to_string()),
472            line: 1,
473        }];
474        let result = build_output("case", "T", "", &[], &entities, &[], &[]).unwrap();
475        assert!(result.case_pending.is_empty());
476    }
477}