Skip to main content

weave_content/
domain.rs

1//! Domain types for the OSINT case graph.
2//!
3//! These types represent the target data model per ADR-014. They are
4//! pure value objects with no infrastructure dependencies.
5
6use std::fmt;
7
8use serde::Serialize;
9
10// ---------------------------------------------------------------------------
11// Entity labels
12// ---------------------------------------------------------------------------
13
14/// Graph node label — determines which fields are valid on an entity.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum EntityLabel {
18    Person,
19    Organization,
20    Event,
21    Document,
22    Asset,
23    Case,
24}
25
26impl fmt::Display for EntityLabel {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            Self::Person => write!(f, "person"),
30            Self::Organization => write!(f, "organization"),
31            Self::Event => write!(f, "event"),
32            Self::Document => write!(f, "document"),
33            Self::Asset => write!(f, "asset"),
34            Self::Case => write!(f, "case"),
35        }
36    }
37}
38
39// ---------------------------------------------------------------------------
40// Person enums
41// ---------------------------------------------------------------------------
42
43/// Role a person holds (multiple allowed per person).
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum Role {
47    Politician,
48    Executive,
49    CivilServant,
50    Military,
51    Judiciary,
52    LawEnforcement,
53    Journalist,
54    Academic,
55    Activist,
56    Athlete,
57    Lawyer,
58    Lobbyist,
59    Banker,
60    Accountant,
61    Consultant,
62    /// Free-form value not in the predefined list.
63    Custom(String),
64}
65
66/// Maximum length of a custom enum value.
67const MAX_CUSTOM_LEN: usize = 100;
68
69impl Role {
70    /// All known non-custom values as `&str`.
71    pub const KNOWN: &[&str] = &[
72        "politician",
73        "executive",
74        "civil_servant",
75        "military",
76        "judiciary",
77        "law_enforcement",
78        "journalist",
79        "academic",
80        "activist",
81        "athlete",
82        "lawyer",
83        "lobbyist",
84        "banker",
85        "accountant",
86        "consultant",
87    ];
88}
89
90/// Status of a person.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
92#[serde(rename_all = "snake_case")]
93pub enum PersonStatus {
94    Active,
95    Deceased,
96    Imprisoned,
97    Fugitive,
98    Acquitted,
99    Pardoned,
100}
101
102impl PersonStatus {
103    pub const KNOWN: &[&str] = &[
104        "active",
105        "deceased",
106        "imprisoned",
107        "fugitive",
108        "acquitted",
109        "pardoned",
110    ];
111}
112
113// ---------------------------------------------------------------------------
114// Organization enums
115// ---------------------------------------------------------------------------
116
117/// Type of organization.
118#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum OrgType {
121    GovernmentMinistry,
122    GovernmentAgency,
123    LocalGovernment,
124    Legislature,
125    Court,
126    LawEnforcement,
127    Prosecutor,
128    Regulator,
129    PoliticalParty,
130    StateEnterprise,
131    Corporation,
132    Bank,
133    Ngo,
134    Media,
135    University,
136    SportsClub,
137    SportsBody,
138    TradeUnion,
139    LobbyGroup,
140    Military,
141    ReligiousBody,
142    Custom(String),
143}
144
145impl OrgType {
146    pub const KNOWN: &[&str] = &[
147        "government_ministry",
148        "government_agency",
149        "local_government",
150        "legislature",
151        "court",
152        "law_enforcement",
153        "prosecutor",
154        "regulator",
155        "political_party",
156        "state_enterprise",
157        "corporation",
158        "bank",
159        "ngo",
160        "media",
161        "university",
162        "sports_club",
163        "sports_body",
164        "trade_union",
165        "lobby_group",
166        "military",
167        "religious_body",
168    ];
169}
170
171/// Status of an organization.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
173#[serde(rename_all = "snake_case")]
174pub enum OrgStatus {
175    Active,
176    Dissolved,
177    Suspended,
178    Merged,
179}
180
181impl OrgStatus {
182    pub const KNOWN: &[&str] = &["active", "dissolved", "suspended", "merged"];
183}
184
185// ---------------------------------------------------------------------------
186// Event enums
187// ---------------------------------------------------------------------------
188
189/// Type of event.
190#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
191#[serde(rename_all = "snake_case")]
192pub enum EventType {
193    Arrest,
194    Indictment,
195    Trial,
196    Conviction,
197    Acquittal,
198    Sentencing,
199    Appeal,
200    Pardon,
201    Parole,
202    Bribery,
203    Embezzlement,
204    Fraud,
205    Extortion,
206    MoneyLaundering,
207    Murder,
208    Assault,
209    Dismissal,
210    Resignation,
211    Appointment,
212    Election,
213    InvestigationOpened,
214    InvestigationClosed,
215    Raid,
216    Seizure,
217    Warrant,
218    FugitiveFlight,
219    FugitiveCapture,
220    PolicyChange,
221    ContractAward,
222    FinancialDefault,
223    Bailout,
224    WhistleblowerReport,
225    Custom(String),
226}
227
228impl EventType {
229    pub const KNOWN: &[&str] = &[
230        "arrest",
231        "indictment",
232        "trial",
233        "conviction",
234        "acquittal",
235        "sentencing",
236        "appeal",
237        "pardon",
238        "parole",
239        "bribery",
240        "embezzlement",
241        "fraud",
242        "extortion",
243        "money_laundering",
244        "murder",
245        "assault",
246        "dismissal",
247        "resignation",
248        "appointment",
249        "election",
250        "investigation_opened",
251        "investigation_closed",
252        "raid",
253        "seizure",
254        "warrant",
255        "fugitive_flight",
256        "fugitive_capture",
257        "policy_change",
258        "contract_award",
259        "financial_default",
260        "bailout",
261        "whistleblower_report",
262    ];
263}
264
265/// Event severity.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
267#[serde(rename_all = "snake_case")]
268pub enum Severity {
269    Minor,
270    Significant,
271    Major,
272    Critical,
273}
274
275impl Severity {
276    pub const KNOWN: &[&str] = &["minor", "significant", "major", "critical"];
277}
278
279// ---------------------------------------------------------------------------
280// Document enums
281// ---------------------------------------------------------------------------
282
283/// Type of document.
284#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
285#[serde(rename_all = "snake_case")]
286pub enum DocType {
287    CourtRuling,
288    Indictment,
289    ChargeSheet,
290    Warrant,
291    Contract,
292    Permit,
293    AuditReport,
294    FinancialDisclosure,
295    Legislation,
296    Regulation,
297    PressRelease,
298    InvestigationReport,
299    SanctionsNotice,
300    Custom(String),
301}
302
303impl DocType {
304    pub const KNOWN: &[&str] = &[
305        "court_ruling",
306        "indictment",
307        "charge_sheet",
308        "warrant",
309        "contract",
310        "permit",
311        "audit_report",
312        "financial_disclosure",
313        "legislation",
314        "regulation",
315        "press_release",
316        "investigation_report",
317        "sanctions_notice",
318    ];
319}
320
321// ---------------------------------------------------------------------------
322// Asset enums
323// ---------------------------------------------------------------------------
324
325/// Type of asset.
326#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
327#[serde(rename_all = "snake_case")]
328pub enum AssetType {
329    Cash,
330    BankAccount,
331    RealEstate,
332    Vehicle,
333    Equity,
334    ContractValue,
335    Grant,
336    BudgetAllocation,
337    SeizedAsset,
338    Custom(String),
339}
340
341impl AssetType {
342    pub const KNOWN: &[&str] = &[
343        "cash",
344        "bank_account",
345        "real_estate",
346        "vehicle",
347        "equity",
348        "contract_value",
349        "grant",
350        "budget_allocation",
351        "seized_asset",
352    ];
353}
354
355/// Status of an asset.
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
357#[serde(rename_all = "snake_case")]
358pub enum AssetStatus {
359    Active,
360    Frozen,
361    Seized,
362    Forfeited,
363    Returned,
364}
365
366impl AssetStatus {
367    pub const KNOWN: &[&str] = &["active", "frozen", "seized", "forfeited", "returned"];
368}
369
370// ---------------------------------------------------------------------------
371// Case enums
372// ---------------------------------------------------------------------------
373
374/// Type of case.
375#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
376#[serde(rename_all = "snake_case")]
377pub enum CaseType {
378    Corruption,
379    Fraud,
380    Bribery,
381    Embezzlement,
382    Murder,
383    Assault,
384    CivilRights,
385    Regulatory,
386    Political,
387    Custom(String),
388}
389
390impl CaseType {
391    pub const KNOWN: &[&str] = &[
392        "corruption",
393        "fraud",
394        "bribery",
395        "embezzlement",
396        "murder",
397        "assault",
398        "civil_rights",
399        "regulatory",
400        "political",
401    ];
402}
403
404/// Status of a case.
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
406#[serde(rename_all = "snake_case")]
407pub enum CaseStatus {
408    Open,
409    UnderInvestigation,
410    Trial,
411    Convicted,
412    Acquitted,
413    Closed,
414    Appeal,
415    Pardoned,
416}
417
418impl CaseStatus {
419    pub const KNOWN: &[&str] = &[
420        "open",
421        "under_investigation",
422        "trial",
423        "convicted",
424        "acquitted",
425        "closed",
426        "appeal",
427        "pardoned",
428    ];
429}
430
431// ---------------------------------------------------------------------------
432// Structured value types
433// ---------------------------------------------------------------------------
434
435/// Monetary amount with currency and human-readable display.
436///
437/// `amount` is in the smallest currency unit (e.g. cents for USD, sen for IDR).
438#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
439pub struct Money {
440    pub amount: i64,
441    pub currency: String,
442    pub display: String,
443}
444
445/// Maximum length of the `currency` field (ISO 4217 = 3 chars).
446pub const MAX_CURRENCY_LEN: usize = 3;
447
448/// Maximum length of the `display` field.
449pub const MAX_MONEY_DISPLAY_LEN: usize = 100;
450
451/// Geographic jurisdiction: ISO 3166-1 country code with optional subdivision.
452#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
453pub struct Jurisdiction {
454    /// ISO 3166-1 alpha-2 country code (e.g. `ID`, `GB`).
455    pub country: String,
456    /// Optional subdivision name (e.g. `South Sulawesi`).
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub subdivision: Option<String>,
459}
460
461/// Maximum length of the `country` field (ISO 3166-1 alpha-2 = 2 chars).
462pub const MAX_COUNTRY_LEN: usize = 2;
463
464/// Maximum length of the `subdivision` field.
465pub const MAX_SUBDIVISION_LEN: usize = 200;
466
467/// A source of information (news article, official document, etc.).
468#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
469pub struct Source {
470    /// HTTPS URL of the source.
471    pub url: String,
472    /// Extracted domain (e.g. `kompas.com`).
473    pub domain: String,
474    /// Article or document title.
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub title: Option<String>,
477    /// Publication date (ISO 8601 date string).
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub published_at: Option<String>,
480    /// Wayback Machine or other archive URL.
481    #[serde(skip_serializing_if = "Option::is_none")]
482    pub archived_url: Option<String>,
483    /// ISO 639-1 language code (e.g. `id`, `en`).
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub language: Option<String>,
486}
487
488/// Maximum length of a source URL.
489pub const MAX_SOURCE_URL_LEN: usize = 2048;
490
491/// Maximum length of a source domain.
492pub const MAX_SOURCE_DOMAIN_LEN: usize = 253;
493
494/// Maximum length of a source title.
495pub const MAX_SOURCE_TITLE_LEN: usize = 300;
496
497/// Maximum length of a source language code (ISO 639-1 = 2 chars).
498pub const MAX_SOURCE_LANGUAGE_LEN: usize = 2;
499
500// ---------------------------------------------------------------------------
501// Amount entries (structured financial amounts)
502// ---------------------------------------------------------------------------
503
504/// A single financial amount entry with currency, optional label, and
505/// approximation flag.
506///
507/// Matches the Elixir `Loom.Graph.Amount` struct. Stored as a JSON array
508/// string in Neo4j (`n.amounts` / `r.amounts`).
509///
510/// ## DSL Syntax
511///
512/// `[~]<value> <currency> [label]` — pipe-separated for multiple entries.
513///
514/// ```text
515/// 660000 USD bribe
516/// ~16800000000000 IDR state_loss
517/// 660000 USD bribe | 250000000 IDR fine
518/// ```
519#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
520pub struct AmountEntry {
521    pub value: i64,
522    pub currency: String,
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub label: Option<String>,
525    pub approximate: bool,
526}
527
528/// Maximum number of amount entries per field.
529pub const MAX_AMOUNT_ENTRIES: usize = 10;
530
531/// Maximum length of an amount label.
532const MAX_AMOUNT_LABEL_LEN: usize = 50;
533
534/// Known amount label values (matches Elixir `EnumField` `:amount_label`).
535pub const AMOUNT_LABEL_KNOWN: &[&str] = &[
536    "bribe",
537    "fine",
538    "restitution",
539    "state_loss",
540    "kickback",
541    "embezzlement",
542    "fraud",
543    "gratification",
544    "bailout",
545    "procurement",
546    "penalty",
547    "fee",
548    "donation",
549    "loan",
550];
551
552impl AmountEntry {
553    /// Parse a pipe-separated DSL string into a list of `AmountEntry`.
554    ///
555    /// Returns an empty vec for `None` or blank input.
556    pub fn parse_dsl(input: &str) -> Result<Vec<Self>, String> {
557        let input = input.trim();
558        if input.is_empty() {
559            return Ok(Vec::new());
560        }
561
562        let entries: Vec<&str> = input
563            .split('|')
564            .map(str::trim)
565            .filter(|s| !s.is_empty())
566            .collect();
567
568        if entries.len() > MAX_AMOUNT_ENTRIES {
569            return Err(format!(
570                "too many amount entries ({}, max {MAX_AMOUNT_ENTRIES})",
571                entries.len()
572            ));
573        }
574
575        entries.iter().map(|e| Self::parse_one(e)).collect()
576    }
577
578    fn parse_one(entry: &str) -> Result<Self, String> {
579        let (approximate, rest) = if let Some(r) = entry.strip_prefix('~') {
580            (true, r.trim_start())
581        } else {
582            (false, entry)
583        };
584
585        let parts: Vec<&str> = rest.splitn(3, char::is_whitespace).collect();
586        match parts.len() {
587            2 | 3 => {
588                let value = parts[0]
589                    .parse::<i64>()
590                    .map_err(|_| format!("invalid amount value: {:?}", parts[0]))?;
591                let currency = Self::validate_currency(parts[1])?;
592                let label = if parts.len() == 3 {
593                    Some(Self::validate_label(parts[2])?)
594                } else {
595                    None
596                };
597                Ok(Self {
598                    value,
599                    currency,
600                    label,
601                    approximate,
602                })
603            }
604            _ => Err(format!("invalid amount format: {entry:?}")),
605        }
606    }
607
608    fn validate_currency(s: &str) -> Result<String, String> {
609        let upper = s.to_uppercase();
610        if upper.len() > MAX_CURRENCY_LEN
611            || upper.is_empty()
612            || !upper.chars().all(|c| c.is_ascii_uppercase())
613        {
614            return Err(format!("invalid currency: {s:?}"));
615        }
616        Ok(upper)
617    }
618
619    fn validate_label(s: &str) -> Result<String, String> {
620        if s.len() > MAX_AMOUNT_LABEL_LEN {
621            return Err(format!("amount label too long: {s:?}"));
622        }
623        if AMOUNT_LABEL_KNOWN.contains(&s) || parse_custom(s).is_some() {
624            Ok(s.to_string())
625        } else {
626            Err(format!(
627                "unknown amount label: {s:?} (use custom:Value for custom)"
628            ))
629        }
630    }
631
632    /// Format for human display (matches Elixir `Amount.format/1`).
633    pub fn format_display(&self) -> String {
634        let prefix = if self.approximate { "~" } else { "" };
635        let formatted_value = format_human_number(self.value);
636        let label_suffix = match &self.label {
637            None => String::new(),
638            Some(l) => format!(" ({})", l.replace('_', " ")),
639        };
640        format!("{prefix}{} {formatted_value}{label_suffix}", self.currency)
641    }
642
643    /// Format a slice of entries for display, separated by "; ".
644    pub fn format_list(entries: &[Self]) -> String {
645        entries
646            .iter()
647            .map(Self::format_display)
648            .collect::<Vec<_>>()
649            .join("; ")
650    }
651}
652
653impl fmt::Display for AmountEntry {
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        write!(f, "{}", self.format_display())
656    }
657}
658
659/// Format a number for human display.
660///
661/// Numbers >= 1 million use word suffixes (million, billion, trillion).
662/// Numbers < 1 million use dot-separated thousands (e.g. 660.000).
663///
664/// Examples:
665/// - `660_000` → `"660.000"`
666/// - `1_500_000` → `"1.5 million"`
667/// - `250_000_000` → `"250 million"`
668/// - `4_580_000_000_000` → `"4.58 trillion"`
669/// - `144_000_000_000_000` → `"144 trillion"`
670fn format_human_number(n: i64) -> String {
671    let abs = n.unsigned_abs();
672    let neg = if n < 0 { "-" } else { "" };
673
674    let (divisor, suffix) = if abs >= 1_000_000_000_000 {
675        (1_000_000_000_000_u64, "trillion")
676    } else if abs >= 1_000_000_000 {
677        (1_000_000_000, "billion")
678    } else if abs >= 1_000_000 {
679        (1_000_000, "million")
680    } else {
681        return format_integer(n);
682    };
683
684    let whole = abs / divisor;
685    let remainder = abs % divisor;
686
687    if remainder == 0 {
688        return format!("{neg}{whole} {suffix}");
689    }
690
691    // Show up to 2 significant decimal digits
692    let frac = (remainder * 100) / divisor;
693    if frac == 0 {
694        format!("{neg}{whole} {suffix}")
695    } else if frac.is_multiple_of(10) {
696        format!("{neg}{whole}.{} {suffix}", frac / 10)
697    } else {
698        format!("{neg}{whole}.{frac:02} {suffix}")
699    }
700}
701
702/// Format an integer with dot separators (e.g. 660000 -> "660.000").
703/// Used for numbers below 1 million.
704fn format_integer(n: i64) -> String {
705    let s = n.to_string();
706    let bytes = s.as_bytes();
707    let mut result = String::with_capacity(s.len() + s.len() / 3);
708    let start = usize::from(n < 0);
709    if n < 0 {
710        result.push('-');
711    }
712    let digits = &bytes[start..];
713    for (i, &b) in digits.iter().enumerate() {
714        if i > 0 && (digits.len() - i).is_multiple_of(3) {
715            result.push('.');
716        }
717        result.push(b as char);
718    }
719    result
720}
721
722// ---------------------------------------------------------------------------
723// Parsing helpers
724// ---------------------------------------------------------------------------
725
726/// Parse a `custom:Value` string. Returns `Some(value)` if the prefix is
727/// present and the value is within length limits, `None` otherwise.
728pub fn parse_custom(value: &str) -> Option<&str> {
729    let custom = value.strip_prefix("custom:")?;
730    if custom.is_empty() || custom.len() > MAX_CUSTOM_LEN {
731        return None;
732    }
733    Some(custom)
734}
735
736// ---------------------------------------------------------------------------
737// Tests
738// ---------------------------------------------------------------------------
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn entity_label_display() {
746        assert_eq!(EntityLabel::Person.to_string(), "person");
747        assert_eq!(EntityLabel::Organization.to_string(), "organization");
748        assert_eq!(EntityLabel::Event.to_string(), "event");
749        assert_eq!(EntityLabel::Document.to_string(), "document");
750        assert_eq!(EntityLabel::Asset.to_string(), "asset");
751        assert_eq!(EntityLabel::Case.to_string(), "case");
752    }
753
754    #[test]
755    fn entity_label_serializes_snake_case() {
756        let json = serde_json::to_string(&EntityLabel::Organization).unwrap_or_default();
757        assert_eq!(json, "\"organization\"");
758    }
759
760    #[test]
761    fn money_serialization() {
762        let m = Money {
763            amount: 500_000_000_000,
764            currency: "IDR".into(),
765            display: "Rp 500 billion".into(),
766        };
767        let json = serde_json::to_string(&m).unwrap_or_default();
768        assert!(json.contains("\"amount\":500000000000"));
769        assert!(json.contains("\"currency\":\"IDR\""));
770        assert!(json.contains("\"display\":\"Rp 500 billion\""));
771    }
772
773    #[test]
774    fn jurisdiction_without_subdivision() {
775        let j = Jurisdiction {
776            country: "ID".into(),
777            subdivision: None,
778        };
779        let json = serde_json::to_string(&j).unwrap_or_default();
780        assert!(json.contains("\"country\":\"ID\""));
781        assert!(!json.contains("subdivision"));
782    }
783
784    #[test]
785    fn jurisdiction_with_subdivision() {
786        let j = Jurisdiction {
787            country: "ID".into(),
788            subdivision: Some("South Sulawesi".into()),
789        };
790        let json = serde_json::to_string(&j).unwrap_or_default();
791        assert!(json.contains("\"subdivision\":\"South Sulawesi\""));
792    }
793
794    #[test]
795    fn source_minimal() {
796        let s = Source {
797            url: "https://kompas.com/article".into(),
798            domain: "kompas.com".into(),
799            title: None,
800            published_at: None,
801            archived_url: None,
802            language: None,
803        };
804        let json = serde_json::to_string(&s).unwrap_or_default();
805        assert!(json.contains("\"domain\":\"kompas.com\""));
806        assert!(!json.contains("title"));
807        assert!(!json.contains("language"));
808    }
809
810    #[test]
811    fn source_full() {
812        let s = Source {
813            url: "https://kompas.com/article".into(),
814            domain: "kompas.com".into(),
815            title: Some("Breaking news".into()),
816            published_at: Some("2024-01-15".into()),
817            archived_url: Some(
818                "https://web.archive.org/web/2024/https://kompas.com/article".into(),
819            ),
820            language: Some("id".into()),
821        };
822        let json = serde_json::to_string(&s).unwrap_or_default();
823        assert!(json.contains("\"title\":\"Breaking news\""));
824        assert!(json.contains("\"language\":\"id\""));
825    }
826
827    #[test]
828    fn parse_custom_valid() {
829        assert_eq!(parse_custom("custom:Kit Manager"), Some("Kit Manager"));
830    }
831
832    #[test]
833    fn parse_custom_empty() {
834        assert_eq!(parse_custom("custom:"), None);
835    }
836
837    #[test]
838    fn parse_custom_too_long() {
839        let long = format!("custom:{}", "a".repeat(101));
840        assert_eq!(parse_custom(&long), None);
841    }
842
843    #[test]
844    fn parse_custom_no_prefix() {
845        assert_eq!(parse_custom("politician"), None);
846    }
847
848    #[test]
849    fn amount_entry_parse_simple() {
850        let entries = AmountEntry::parse_dsl("660000 USD bribe").unwrap();
851        assert_eq!(entries.len(), 1);
852        assert_eq!(entries[0].value, 660_000);
853        assert_eq!(entries[0].currency, "USD");
854        assert_eq!(entries[0].label.as_deref(), Some("bribe"));
855        assert!(!entries[0].approximate);
856    }
857
858    #[test]
859    fn amount_entry_parse_approximate() {
860        let entries = AmountEntry::parse_dsl("~16800000000000 IDR state_loss").unwrap();
861        assert_eq!(entries.len(), 1);
862        assert!(entries[0].approximate);
863        assert_eq!(entries[0].value, 16_800_000_000_000);
864        assert_eq!(entries[0].currency, "IDR");
865        assert_eq!(entries[0].label.as_deref(), Some("state_loss"));
866    }
867
868    #[test]
869    fn amount_entry_parse_multiple() {
870        let entries = AmountEntry::parse_dsl("660000 USD bribe | 250000000 IDR fine").unwrap();
871        assert_eq!(entries.len(), 2);
872        assert_eq!(entries[0].currency, "USD");
873        assert_eq!(entries[1].currency, "IDR");
874    }
875
876    #[test]
877    fn amount_entry_parse_no_label() {
878        let entries = AmountEntry::parse_dsl("1000 EUR").unwrap();
879        assert_eq!(entries.len(), 1);
880        assert!(entries[0].label.is_none());
881    }
882
883    #[test]
884    fn amount_entry_parse_empty() {
885        let entries = AmountEntry::parse_dsl("").unwrap();
886        assert!(entries.is_empty());
887    }
888
889    #[test]
890    fn amount_entry_parse_invalid_value() {
891        assert!(AmountEntry::parse_dsl("abc USD").is_err());
892    }
893
894    #[test]
895    fn amount_entry_parse_unknown_label() {
896        assert!(AmountEntry::parse_dsl("1000 USD unknown_label").is_err());
897    }
898
899    #[test]
900    fn amount_entry_parse_custom_label() {
901        let entries = AmountEntry::parse_dsl("1000 USD custom:MyLabel").unwrap();
902        assert_eq!(entries[0].label.as_deref(), Some("custom:MyLabel"));
903    }
904
905    #[test]
906    fn amount_entry_format_display() {
907        let entry = AmountEntry {
908            value: 660_000,
909            currency: "USD".into(),
910            label: Some("bribe".into()),
911            approximate: false,
912        };
913        assert_eq!(entry.format_display(), "USD 660.000 (bribe)");
914    }
915
916    #[test]
917    fn amount_entry_format_approximate() {
918        let entry = AmountEntry {
919            value: 16_800_000_000_000,
920            currency: "IDR".into(),
921            label: Some("state_loss".into()),
922            approximate: true,
923        };
924        assert_eq!(entry.format_display(), "~IDR 16.8 trillion (state loss)");
925    }
926
927    #[test]
928    fn amount_entry_format_no_label() {
929        let entry = AmountEntry {
930            value: 1000,
931            currency: "EUR".into(),
932            label: None,
933            approximate: false,
934        };
935        assert_eq!(entry.format_display(), "EUR 1.000");
936    }
937
938    #[test]
939    fn amount_entry_serialization() {
940        let entry = AmountEntry {
941            value: 660_000,
942            currency: "USD".into(),
943            label: Some("bribe".into()),
944            approximate: false,
945        };
946        let json = serde_json::to_string(&entry).unwrap_or_default();
947        assert!(json.contains("\"value\":660000"));
948        assert!(json.contains("\"currency\":\"USD\""));
949        assert!(json.contains("\"label\":\"bribe\""));
950        assert!(json.contains("\"approximate\":false"));
951    }
952
953    #[test]
954    fn amount_entry_serialization_no_label() {
955        let entry = AmountEntry {
956            value: 1000,
957            currency: "EUR".into(),
958            label: None,
959            approximate: false,
960        };
961        let json = serde_json::to_string(&entry).unwrap_or_default();
962        assert!(!json.contains("label"));
963    }
964
965    #[test]
966    fn format_integer_commas() {
967        assert_eq!(format_integer(0), "0");
968        assert_eq!(format_integer(999), "999");
969        assert_eq!(format_integer(1000), "1.000");
970        assert_eq!(format_integer(1_000_000), "1.000.000");
971        assert_eq!(format_integer(16_800_000_000_000), "16.800.000.000.000");
972    }
973
974    #[test]
975    fn format_human_number_below_million() {
976        assert_eq!(format_human_number(0), "0");
977        assert_eq!(format_human_number(999), "999");
978        assert_eq!(format_human_number(1_000), "1.000");
979        assert_eq!(format_human_number(660_000), "660.000");
980        assert_eq!(format_human_number(999_999), "999.999");
981    }
982
983    #[test]
984    fn format_human_number_millions() {
985        assert_eq!(format_human_number(1_000_000), "1 million");
986        assert_eq!(format_human_number(1_500_000), "1.5 million");
987        assert_eq!(format_human_number(250_000_000), "250 million");
988        assert_eq!(format_human_number(1_230_000), "1.23 million");
989    }
990
991    #[test]
992    fn format_human_number_billions() {
993        assert_eq!(format_human_number(1_000_000_000), "1 billion");
994        assert_eq!(format_human_number(4_580_000_000), "4.58 billion");
995        assert_eq!(format_human_number(100_000_000_000), "100 billion");
996    }
997
998    #[test]
999    fn format_human_number_trillions() {
1000        assert_eq!(format_human_number(1_000_000_000_000), "1 trillion");
1001        assert_eq!(format_human_number(4_580_000_000_000), "4.58 trillion");
1002        assert_eq!(format_human_number(144_000_000_000_000), "144 trillion");
1003        assert_eq!(format_human_number(16_800_000_000_000), "16.8 trillion");
1004    }
1005
1006    #[test]
1007    fn amount_entry_too_many() {
1008        let dsl = (0..11)
1009            .map(|i| format!("{i} USD"))
1010            .collect::<Vec<_>>()
1011            .join(" | ");
1012        assert!(AmountEntry::parse_dsl(&dsl).is_err());
1013    }
1014
1015    #[test]
1016    fn role_known_values_count() {
1017        assert_eq!(Role::KNOWN.len(), 15);
1018    }
1019
1020    #[test]
1021    fn event_type_known_values_count() {
1022        assert_eq!(EventType::KNOWN.len(), 32);
1023    }
1024
1025    #[test]
1026    fn org_type_known_values_count() {
1027        assert_eq!(OrgType::KNOWN.len(), 21);
1028    }
1029
1030    #[test]
1031    fn severity_known_values_count() {
1032        assert_eq!(Severity::KNOWN.len(), 4);
1033    }
1034}