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    CivilRights,
384    Regulatory,
385    Political,
386    Custom(String),
387}
388
389impl CaseType {
390    pub const KNOWN: &[&str] = &[
391        "corruption",
392        "fraud",
393        "bribery",
394        "embezzlement",
395        "murder",
396        "civil_rights",
397        "regulatory",
398        "political",
399    ];
400}
401
402/// Status of a case.
403#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
404#[serde(rename_all = "snake_case")]
405pub enum CaseStatus {
406    Open,
407    UnderInvestigation,
408    Trial,
409    Convicted,
410    Acquitted,
411    Closed,
412    Appeal,
413    Pardoned,
414}
415
416impl CaseStatus {
417    pub const KNOWN: &[&str] = &[
418        "open",
419        "under_investigation",
420        "trial",
421        "convicted",
422        "acquitted",
423        "closed",
424        "appeal",
425        "pardoned",
426    ];
427}
428
429// ---------------------------------------------------------------------------
430// Structured value types
431// ---------------------------------------------------------------------------
432
433/// Monetary amount with currency and human-readable display.
434///
435/// `amount` is in the smallest currency unit (e.g. cents for USD, sen for IDR).
436#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
437pub struct Money {
438    pub amount: i64,
439    pub currency: String,
440    pub display: String,
441}
442
443/// Maximum length of the `currency` field (ISO 4217 = 3 chars).
444pub const MAX_CURRENCY_LEN: usize = 3;
445
446/// Maximum length of the `display` field.
447pub const MAX_MONEY_DISPLAY_LEN: usize = 100;
448
449/// Geographic jurisdiction: ISO 3166-1 country code with optional subdivision.
450#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
451pub struct Jurisdiction {
452    /// ISO 3166-1 alpha-2 country code (e.g. `ID`, `GB`).
453    pub country: String,
454    /// Optional subdivision name (e.g. `South Sulawesi`).
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub subdivision: Option<String>,
457}
458
459/// Maximum length of the `country` field (ISO 3166-1 alpha-2 = 2 chars).
460pub const MAX_COUNTRY_LEN: usize = 2;
461
462/// Maximum length of the `subdivision` field.
463pub const MAX_SUBDIVISION_LEN: usize = 200;
464
465/// A source of information (news article, official document, etc.).
466#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
467pub struct Source {
468    /// HTTPS URL of the source.
469    pub url: String,
470    /// Extracted domain (e.g. `kompas.com`).
471    pub domain: String,
472    /// Article or document title.
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub title: Option<String>,
475    /// Publication date (ISO 8601 date string).
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub published_at: Option<String>,
478    /// Wayback Machine or other archive URL.
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub archived_url: Option<String>,
481    /// ISO 639-1 language code (e.g. `id`, `en`).
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub language: Option<String>,
484}
485
486/// Maximum length of a source URL.
487pub const MAX_SOURCE_URL_LEN: usize = 2048;
488
489/// Maximum length of a source domain.
490pub const MAX_SOURCE_DOMAIN_LEN: usize = 253;
491
492/// Maximum length of a source title.
493pub const MAX_SOURCE_TITLE_LEN: usize = 300;
494
495/// Maximum length of a source language code (ISO 639-1 = 2 chars).
496pub const MAX_SOURCE_LANGUAGE_LEN: usize = 2;
497
498// ---------------------------------------------------------------------------
499// Amount entries (structured financial amounts)
500// ---------------------------------------------------------------------------
501
502/// A single financial amount entry with currency, optional label, and
503/// approximation flag.
504///
505/// Matches the Elixir `Loom.Graph.Amount` struct. Stored as a JSON array
506/// string in Neo4j (`n.amounts` / `r.amounts`).
507///
508/// ## DSL Syntax
509///
510/// `[~]<value> <currency> [label]` — pipe-separated for multiple entries.
511///
512/// ```text
513/// 660000 USD bribe
514/// ~16800000000000 IDR state_loss
515/// 660000 USD bribe | 250000000 IDR fine
516/// ```
517#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
518pub struct AmountEntry {
519    pub value: i64,
520    pub currency: String,
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub label: Option<String>,
523    pub approximate: bool,
524}
525
526/// Maximum number of amount entries per field.
527pub const MAX_AMOUNT_ENTRIES: usize = 10;
528
529/// Maximum length of an amount label.
530const MAX_AMOUNT_LABEL_LEN: usize = 50;
531
532/// Known amount label values (matches Elixir `EnumField` `:amount_label`).
533pub const AMOUNT_LABEL_KNOWN: &[&str] = &[
534    "bribe",
535    "fine",
536    "restitution",
537    "state_loss",
538    "kickback",
539    "embezzlement",
540    "fraud",
541    "gratification",
542    "bailout",
543    "procurement",
544    "penalty",
545    "fee",
546    "donation",
547    "loan",
548];
549
550impl AmountEntry {
551    /// Parse a pipe-separated DSL string into a list of `AmountEntry`.
552    ///
553    /// Returns an empty vec for `None` or blank input.
554    pub fn parse_dsl(input: &str) -> Result<Vec<Self>, String> {
555        let input = input.trim();
556        if input.is_empty() {
557            return Ok(Vec::new());
558        }
559
560        let entries: Vec<&str> = input
561            .split('|')
562            .map(str::trim)
563            .filter(|s| !s.is_empty())
564            .collect();
565
566        if entries.len() > MAX_AMOUNT_ENTRIES {
567            return Err(format!(
568                "too many amount entries ({}, max {MAX_AMOUNT_ENTRIES})",
569                entries.len()
570            ));
571        }
572
573        entries.iter().map(|e| Self::parse_one(e)).collect()
574    }
575
576    fn parse_one(entry: &str) -> Result<Self, String> {
577        let (approximate, rest) = if let Some(r) = entry.strip_prefix('~') {
578            (true, r.trim_start())
579        } else {
580            (false, entry)
581        };
582
583        let parts: Vec<&str> = rest.splitn(3, char::is_whitespace).collect();
584        match parts.len() {
585            2 | 3 => {
586                let value = parts[0]
587                    .parse::<i64>()
588                    .map_err(|_| format!("invalid amount value: {:?}", parts[0]))?;
589                let currency = Self::validate_currency(parts[1])?;
590                let label = if parts.len() == 3 {
591                    Some(Self::validate_label(parts[2])?)
592                } else {
593                    None
594                };
595                Ok(Self {
596                    value,
597                    currency,
598                    label,
599                    approximate,
600                })
601            }
602            _ => Err(format!("invalid amount format: {entry:?}")),
603        }
604    }
605
606    fn validate_currency(s: &str) -> Result<String, String> {
607        let upper = s.to_uppercase();
608        if upper.len() > MAX_CURRENCY_LEN
609            || upper.is_empty()
610            || !upper.chars().all(|c| c.is_ascii_uppercase())
611        {
612            return Err(format!("invalid currency: {s:?}"));
613        }
614        Ok(upper)
615    }
616
617    fn validate_label(s: &str) -> Result<String, String> {
618        if s.len() > MAX_AMOUNT_LABEL_LEN {
619            return Err(format!("amount label too long: {s:?}"));
620        }
621        if AMOUNT_LABEL_KNOWN.contains(&s) || parse_custom(s).is_some() {
622            Ok(s.to_string())
623        } else {
624            Err(format!(
625                "unknown amount label: {s:?} (use custom:Value for custom)"
626            ))
627        }
628    }
629
630    /// Format for human display (matches Elixir `Amount.format/1`).
631    pub fn format_display(&self) -> String {
632        let prefix = if self.approximate { "~" } else { "" };
633        let formatted_value = format_human_number(self.value);
634        let label_suffix = match &self.label {
635            None => String::new(),
636            Some(l) => format!(" ({})", l.replace('_', " ")),
637        };
638        format!("{prefix}{} {formatted_value}{label_suffix}", self.currency)
639    }
640
641    /// Format a slice of entries for display, separated by "; ".
642    pub fn format_list(entries: &[Self]) -> String {
643        entries
644            .iter()
645            .map(Self::format_display)
646            .collect::<Vec<_>>()
647            .join("; ")
648    }
649}
650
651impl fmt::Display for AmountEntry {
652    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
653        write!(f, "{}", self.format_display())
654    }
655}
656
657/// Format a number for human display.
658///
659/// Numbers >= 1 million use word suffixes (million, billion, trillion).
660/// Numbers < 1 million use dot-separated thousands (e.g. 660.000).
661///
662/// Examples:
663/// - `660_000` → `"660.000"`
664/// - `1_500_000` → `"1.5 million"`
665/// - `250_000_000` → `"250 million"`
666/// - `4_580_000_000_000` → `"4.58 trillion"`
667/// - `144_000_000_000_000` → `"144 trillion"`
668fn format_human_number(n: i64) -> String {
669    let abs = n.unsigned_abs();
670    let neg = if n < 0 { "-" } else { "" };
671
672    let (divisor, suffix) = if abs >= 1_000_000_000_000 {
673        (1_000_000_000_000_u64, "trillion")
674    } else if abs >= 1_000_000_000 {
675        (1_000_000_000, "billion")
676    } else if abs >= 1_000_000 {
677        (1_000_000, "million")
678    } else {
679        return format_integer(n);
680    };
681
682    let whole = abs / divisor;
683    let remainder = abs % divisor;
684
685    if remainder == 0 {
686        return format!("{neg}{whole} {suffix}");
687    }
688
689    // Show up to 2 significant decimal digits
690    let frac = (remainder * 100) / divisor;
691    if frac == 0 {
692        format!("{neg}{whole} {suffix}")
693    } else if frac.is_multiple_of(10) {
694        format!("{neg}{whole}.{} {suffix}", frac / 10)
695    } else {
696        format!("{neg}{whole}.{frac:02} {suffix}")
697    }
698}
699
700/// Format an integer with dot separators (e.g. 660000 -> "660.000").
701/// Used for numbers below 1 million.
702fn format_integer(n: i64) -> String {
703    let s = n.to_string();
704    let bytes = s.as_bytes();
705    let mut result = String::with_capacity(s.len() + s.len() / 3);
706    let start = usize::from(n < 0);
707    if n < 0 {
708        result.push('-');
709    }
710    let digits = &bytes[start..];
711    for (i, &b) in digits.iter().enumerate() {
712        if i > 0 && (digits.len() - i).is_multiple_of(3) {
713            result.push('.');
714        }
715        result.push(b as char);
716    }
717    result
718}
719
720// ---------------------------------------------------------------------------
721// Parsing helpers
722// ---------------------------------------------------------------------------
723
724/// Parse a `custom:Value` string. Returns `Some(value)` if the prefix is
725/// present and the value is within length limits, `None` otherwise.
726pub fn parse_custom(value: &str) -> Option<&str> {
727    let custom = value.strip_prefix("custom:")?;
728    if custom.is_empty() || custom.len() > MAX_CUSTOM_LEN {
729        return None;
730    }
731    Some(custom)
732}
733
734// ---------------------------------------------------------------------------
735// Tests
736// ---------------------------------------------------------------------------
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741
742    #[test]
743    fn entity_label_display() {
744        assert_eq!(EntityLabel::Person.to_string(), "person");
745        assert_eq!(EntityLabel::Organization.to_string(), "organization");
746        assert_eq!(EntityLabel::Event.to_string(), "event");
747        assert_eq!(EntityLabel::Document.to_string(), "document");
748        assert_eq!(EntityLabel::Asset.to_string(), "asset");
749        assert_eq!(EntityLabel::Case.to_string(), "case");
750    }
751
752    #[test]
753    fn entity_label_serializes_snake_case() {
754        let json = serde_json::to_string(&EntityLabel::Organization).unwrap_or_default();
755        assert_eq!(json, "\"organization\"");
756    }
757
758    #[test]
759    fn money_serialization() {
760        let m = Money {
761            amount: 500_000_000_000,
762            currency: "IDR".into(),
763            display: "Rp 500 billion".into(),
764        };
765        let json = serde_json::to_string(&m).unwrap_or_default();
766        assert!(json.contains("\"amount\":500000000000"));
767        assert!(json.contains("\"currency\":\"IDR\""));
768        assert!(json.contains("\"display\":\"Rp 500 billion\""));
769    }
770
771    #[test]
772    fn jurisdiction_without_subdivision() {
773        let j = Jurisdiction {
774            country: "ID".into(),
775            subdivision: None,
776        };
777        let json = serde_json::to_string(&j).unwrap_or_default();
778        assert!(json.contains("\"country\":\"ID\""));
779        assert!(!json.contains("subdivision"));
780    }
781
782    #[test]
783    fn jurisdiction_with_subdivision() {
784        let j = Jurisdiction {
785            country: "ID".into(),
786            subdivision: Some("South Sulawesi".into()),
787        };
788        let json = serde_json::to_string(&j).unwrap_or_default();
789        assert!(json.contains("\"subdivision\":\"South Sulawesi\""));
790    }
791
792    #[test]
793    fn source_minimal() {
794        let s = Source {
795            url: "https://kompas.com/article".into(),
796            domain: "kompas.com".into(),
797            title: None,
798            published_at: None,
799            archived_url: None,
800            language: None,
801        };
802        let json = serde_json::to_string(&s).unwrap_or_default();
803        assert!(json.contains("\"domain\":\"kompas.com\""));
804        assert!(!json.contains("title"));
805        assert!(!json.contains("language"));
806    }
807
808    #[test]
809    fn source_full() {
810        let s = Source {
811            url: "https://kompas.com/article".into(),
812            domain: "kompas.com".into(),
813            title: Some("Breaking news".into()),
814            published_at: Some("2024-01-15".into()),
815            archived_url: Some(
816                "https://web.archive.org/web/2024/https://kompas.com/article".into(),
817            ),
818            language: Some("id".into()),
819        };
820        let json = serde_json::to_string(&s).unwrap_or_default();
821        assert!(json.contains("\"title\":\"Breaking news\""));
822        assert!(json.contains("\"language\":\"id\""));
823    }
824
825    #[test]
826    fn parse_custom_valid() {
827        assert_eq!(parse_custom("custom:Kit Manager"), Some("Kit Manager"));
828    }
829
830    #[test]
831    fn parse_custom_empty() {
832        assert_eq!(parse_custom("custom:"), None);
833    }
834
835    #[test]
836    fn parse_custom_too_long() {
837        let long = format!("custom:{}", "a".repeat(101));
838        assert_eq!(parse_custom(&long), None);
839    }
840
841    #[test]
842    fn parse_custom_no_prefix() {
843        assert_eq!(parse_custom("politician"), None);
844    }
845
846    #[test]
847    fn amount_entry_parse_simple() {
848        let entries = AmountEntry::parse_dsl("660000 USD bribe").unwrap();
849        assert_eq!(entries.len(), 1);
850        assert_eq!(entries[0].value, 660_000);
851        assert_eq!(entries[0].currency, "USD");
852        assert_eq!(entries[0].label.as_deref(), Some("bribe"));
853        assert!(!entries[0].approximate);
854    }
855
856    #[test]
857    fn amount_entry_parse_approximate() {
858        let entries = AmountEntry::parse_dsl("~16800000000000 IDR state_loss").unwrap();
859        assert_eq!(entries.len(), 1);
860        assert!(entries[0].approximate);
861        assert_eq!(entries[0].value, 16_800_000_000_000);
862        assert_eq!(entries[0].currency, "IDR");
863        assert_eq!(entries[0].label.as_deref(), Some("state_loss"));
864    }
865
866    #[test]
867    fn amount_entry_parse_multiple() {
868        let entries = AmountEntry::parse_dsl("660000 USD bribe | 250000000 IDR fine").unwrap();
869        assert_eq!(entries.len(), 2);
870        assert_eq!(entries[0].currency, "USD");
871        assert_eq!(entries[1].currency, "IDR");
872    }
873
874    #[test]
875    fn amount_entry_parse_no_label() {
876        let entries = AmountEntry::parse_dsl("1000 EUR").unwrap();
877        assert_eq!(entries.len(), 1);
878        assert!(entries[0].label.is_none());
879    }
880
881    #[test]
882    fn amount_entry_parse_empty() {
883        let entries = AmountEntry::parse_dsl("").unwrap();
884        assert!(entries.is_empty());
885    }
886
887    #[test]
888    fn amount_entry_parse_invalid_value() {
889        assert!(AmountEntry::parse_dsl("abc USD").is_err());
890    }
891
892    #[test]
893    fn amount_entry_parse_unknown_label() {
894        assert!(AmountEntry::parse_dsl("1000 USD unknown_label").is_err());
895    }
896
897    #[test]
898    fn amount_entry_parse_custom_label() {
899        let entries = AmountEntry::parse_dsl("1000 USD custom:MyLabel").unwrap();
900        assert_eq!(entries[0].label.as_deref(), Some("custom:MyLabel"));
901    }
902
903    #[test]
904    fn amount_entry_format_display() {
905        let entry = AmountEntry {
906            value: 660_000,
907            currency: "USD".into(),
908            label: Some("bribe".into()),
909            approximate: false,
910        };
911        assert_eq!(entry.format_display(), "USD 660.000 (bribe)");
912    }
913
914    #[test]
915    fn amount_entry_format_approximate() {
916        let entry = AmountEntry {
917            value: 16_800_000_000_000,
918            currency: "IDR".into(),
919            label: Some("state_loss".into()),
920            approximate: true,
921        };
922        assert_eq!(entry.format_display(), "~IDR 16.8 trillion (state loss)");
923    }
924
925    #[test]
926    fn amount_entry_format_no_label() {
927        let entry = AmountEntry {
928            value: 1000,
929            currency: "EUR".into(),
930            label: None,
931            approximate: false,
932        };
933        assert_eq!(entry.format_display(), "EUR 1.000");
934    }
935
936    #[test]
937    fn amount_entry_serialization() {
938        let entry = AmountEntry {
939            value: 660_000,
940            currency: "USD".into(),
941            label: Some("bribe".into()),
942            approximate: false,
943        };
944        let json = serde_json::to_string(&entry).unwrap_or_default();
945        assert!(json.contains("\"value\":660000"));
946        assert!(json.contains("\"currency\":\"USD\""));
947        assert!(json.contains("\"label\":\"bribe\""));
948        assert!(json.contains("\"approximate\":false"));
949    }
950
951    #[test]
952    fn amount_entry_serialization_no_label() {
953        let entry = AmountEntry {
954            value: 1000,
955            currency: "EUR".into(),
956            label: None,
957            approximate: false,
958        };
959        let json = serde_json::to_string(&entry).unwrap_or_default();
960        assert!(!json.contains("label"));
961    }
962
963    #[test]
964    fn format_integer_commas() {
965        assert_eq!(format_integer(0), "0");
966        assert_eq!(format_integer(999), "999");
967        assert_eq!(format_integer(1000), "1.000");
968        assert_eq!(format_integer(1_000_000), "1.000.000");
969        assert_eq!(format_integer(16_800_000_000_000), "16.800.000.000.000");
970    }
971
972    #[test]
973    fn format_human_number_below_million() {
974        assert_eq!(format_human_number(0), "0");
975        assert_eq!(format_human_number(999), "999");
976        assert_eq!(format_human_number(1_000), "1.000");
977        assert_eq!(format_human_number(660_000), "660.000");
978        assert_eq!(format_human_number(999_999), "999.999");
979    }
980
981    #[test]
982    fn format_human_number_millions() {
983        assert_eq!(format_human_number(1_000_000), "1 million");
984        assert_eq!(format_human_number(1_500_000), "1.5 million");
985        assert_eq!(format_human_number(250_000_000), "250 million");
986        assert_eq!(format_human_number(1_230_000), "1.23 million");
987    }
988
989    #[test]
990    fn format_human_number_billions() {
991        assert_eq!(format_human_number(1_000_000_000), "1 billion");
992        assert_eq!(format_human_number(4_580_000_000), "4.58 billion");
993        assert_eq!(format_human_number(100_000_000_000), "100 billion");
994    }
995
996    #[test]
997    fn format_human_number_trillions() {
998        assert_eq!(format_human_number(1_000_000_000_000), "1 trillion");
999        assert_eq!(format_human_number(4_580_000_000_000), "4.58 trillion");
1000        assert_eq!(format_human_number(144_000_000_000_000), "144 trillion");
1001        assert_eq!(format_human_number(16_800_000_000_000), "16.8 trillion");
1002    }
1003
1004    #[test]
1005    fn amount_entry_too_many() {
1006        let dsl = (0..11)
1007            .map(|i| format!("{i} USD"))
1008            .collect::<Vec<_>>()
1009            .join(" | ");
1010        assert!(AmountEntry::parse_dsl(&dsl).is_err());
1011    }
1012
1013    #[test]
1014    fn role_known_values_count() {
1015        assert_eq!(Role::KNOWN.len(), 15);
1016    }
1017
1018    #[test]
1019    fn event_type_known_values_count() {
1020        assert_eq!(EventType::KNOWN.len(), 32);
1021    }
1022
1023    #[test]
1024    fn org_type_known_values_count() {
1025        assert_eq!(OrgType::KNOWN.len(), 21);
1026    }
1027
1028    #[test]
1029    fn severity_known_values_count() {
1030        assert_eq!(Severity::KNOWN.len(), 4);
1031    }
1032}