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