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