Skip to main content

avatarr_parser/language/
mod.rs

1// Ported from Sonarr v4.0.17.2952 (97e85a90):
2//   src/NzbDrone.Core/Languages/Language.cs
3//   src/NzbDrone.Core/Parser/IsoLanguage.cs
4//   src/NzbDrone.Core/Parser/IsoLanguages.cs
5//   src/NzbDrone.Core/Organizer/FileNameBuilder.cs (Iso639BTMap fragment only)
6//
7// **Relationship to `avatarr_core::Language`:** the integer IDs in this enum
8// are intentionally identical to `crates/core/src/domain/language.rs`'s
9// `Language(i32)` newtype. T11+ will build a `From<Language>` / reverse-map
10// bridge between the two; for now `parser_lang as i32 -> core::Language(i)`
11// is the contract.
12
13pub mod parser;
14pub mod subtitle;
15
16pub use parser::parse_languages;
17pub use subtitle::{
18    SubtitleTitleInfo, parse_basic_subtitle, parse_language_tags, parse_subtitle_language,
19    parse_subtitle_language_information,
20};
21
22/// Spoken / written language identifier with a stable integer ID.
23///
24/// IDs match Sonarr's `Language.cs` static initializers verbatim: 46
25/// contiguous positive variants from `English = 1` through
26/// `Slovenian = 46`, plus `Unknown = 0` and the sentinel `Original = -2`
27/// used by Sonarr's quality-profile machinery to mean "the series'
28/// original audio language", whatever that resolves to at the time.
29///
30/// Total: 48 variants (the m50 plan-spec drafted "47" but C#
31/// `Language.cs:122-178` actually contains 48 entries; standing
32/// rule #1 says C# fidelity wins). See the C# `All` list ordering:
33/// every variant here matches the order in that list, with `Original`
34/// last as in C#.
35#[repr(i32)]
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
37#[non_exhaustive]
38pub enum Language {
39    #[default]
40    Unknown = 0,
41    English = 1,
42    French = 2,
43    Spanish = 3,
44    German = 4,
45    Italian = 5,
46    Danish = 6,
47    Dutch = 7,
48    Japanese = 8,
49    Icelandic = 9,
50    Chinese = 10,
51    Russian = 11,
52    Polish = 12,
53    Vietnamese = 13,
54    Swedish = 14,
55    Norwegian = 15,
56    Finnish = 16,
57    Turkish = 17,
58    Portuguese = 18,
59    Flemish = 19,
60    Greek = 20,
61    Korean = 21,
62    Hungarian = 22,
63    Hebrew = 23,
64    Lithuanian = 24,
65    Czech = 25,
66    Arabic = 26,
67    Hindi = 27,
68    Bulgarian = 28,
69    Malayalam = 29,
70    Ukrainian = 30,
71    Slovak = 31,
72    Thai = 32,
73    PortugueseBrazil = 33,
74    SpanishLatino = 34,
75    Romanian = 35,
76    Latvian = 36,
77    Persian = 37,
78    Catalan = 38,
79    Croatian = 39,
80    Serbian = 40,
81    Bosnian = 41,
82    Estonian = 42,
83    Tamil = 43,
84    Indonesian = 44,
85    Macedonian = 45,
86    Slovenian = 46,
87    Original = -2,
88}
89
90impl From<Language> for i32 {
91    fn from(lang: Language) -> i32 {
92        lang as i32
93    }
94}
95
96impl Language {
97    /// Display name as Sonarr stores it (e.g. `"Portuguese (Brazil)"`).
98    ///
99    /// Mirrors the second arg to each `new Language(id, name)` in
100    /// `Language.cs`. Used by upcoming `parse_language_name` flows for
101    /// reverse lookup; T11+ wires it in.
102    pub fn name(self) -> &'static str {
103        match self {
104            Language::Unknown => "Unknown",
105            Language::English => "English",
106            Language::French => "French",
107            Language::Spanish => "Spanish",
108            Language::German => "German",
109            Language::Italian => "Italian",
110            Language::Danish => "Danish",
111            Language::Dutch => "Dutch",
112            Language::Japanese => "Japanese",
113            Language::Icelandic => "Icelandic",
114            Language::Chinese => "Chinese",
115            Language::Russian => "Russian",
116            Language::Polish => "Polish",
117            Language::Vietnamese => "Vietnamese",
118            Language::Swedish => "Swedish",
119            Language::Norwegian => "Norwegian",
120            Language::Finnish => "Finnish",
121            Language::Turkish => "Turkish",
122            Language::Portuguese => "Portuguese",
123            Language::Flemish => "Flemish",
124            Language::Greek => "Greek",
125            Language::Korean => "Korean",
126            Language::Hungarian => "Hungarian",
127            Language::Hebrew => "Hebrew",
128            Language::Lithuanian => "Lithuanian",
129            Language::Czech => "Czech",
130            Language::Arabic => "Arabic",
131            Language::Hindi => "Hindi",
132            Language::Bulgarian => "Bulgarian",
133            Language::Malayalam => "Malayalam",
134            Language::Ukrainian => "Ukrainian",
135            Language::Slovak => "Slovak",
136            Language::Thai => "Thai",
137            Language::PortugueseBrazil => "Portuguese (Brazil)",
138            Language::SpanishLatino => "Spanish (Latino)",
139            Language::Romanian => "Romanian",
140            Language::Latvian => "Latvian",
141            Language::Persian => "Persian",
142            Language::Catalan => "Catalan",
143            Language::Croatian => "Croatian",
144            Language::Serbian => "Serbian",
145            Language::Bosnian => "Bosnian",
146            Language::Estonian => "Estonian",
147            Language::Tamil => "Tamil",
148            Language::Indonesian => "Indonesian",
149            Language::Macedonian => "Macedonian",
150            Language::Slovenian => "Slovenian",
151            Language::Original => "Original",
152        }
153    }
154}
155
156/// ISO-639 code lookup table + `find()` entrypoint.
157///
158/// Mirrors Sonarr's `IsoLanguages.cs` static `All` HashSet and `Find`
159/// algorithm verbatim. The `IsoLanguage` record carries the original
160/// 4-tuple shape (two-letter, country, three-letter, Language) so a
161/// future caller that needs the full record can graduate from the
162/// `find` shortcut to `find_iso`.
163pub mod iso_languages {
164    use super::Language;
165
166    /// One row of Sonarr's `IsoLanguages.All`: ISO-639-1 (2-letter),
167    /// optional ISO-3166 country code, ISO-639-2/T (3-letter), and the
168    /// `Language` it identifies.
169    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
170    #[non_exhaustive]
171    pub struct IsoLanguage {
172        pub two_letter_code: &'static str,
173        pub country_code: &'static str,
174        pub three_letter_code: &'static str,
175        pub language: Language,
176    }
177
178    /// Static lookup table. Order matters because Sonarr's `Find`
179    /// returns `FirstOrDefault()` when multiple rows match. In
180    /// particular, `("nl", "", "nld", Dutch)` precedes
181    /// `("nl", "", "nld", Flemish)`, so a bare `nl` resolves to Dutch.
182    /// Likewise `("pt", "pt", ...)` precedes `("pt", "br", ...)`.
183    const ALL: &[IsoLanguage] = &[
184        IsoLanguage {
185            two_letter_code: "en",
186            country_code: "",
187            three_letter_code: "eng",
188            language: Language::English,
189        },
190        IsoLanguage {
191            two_letter_code: "fr",
192            country_code: "fr",
193            three_letter_code: "fra",
194            language: Language::French,
195        },
196        IsoLanguage {
197            two_letter_code: "es",
198            country_code: "",
199            three_letter_code: "spa",
200            language: Language::Spanish,
201        },
202        IsoLanguage {
203            two_letter_code: "de",
204            country_code: "de",
205            three_letter_code: "deu",
206            language: Language::German,
207        },
208        IsoLanguage {
209            two_letter_code: "it",
210            country_code: "",
211            three_letter_code: "ita",
212            language: Language::Italian,
213        },
214        IsoLanguage {
215            two_letter_code: "da",
216            country_code: "",
217            three_letter_code: "dan",
218            language: Language::Danish,
219        },
220        IsoLanguage {
221            two_letter_code: "nl",
222            country_code: "",
223            three_letter_code: "nld",
224            language: Language::Dutch,
225        },
226        IsoLanguage {
227            two_letter_code: "ja",
228            country_code: "",
229            three_letter_code: "jpn",
230            language: Language::Japanese,
231        },
232        IsoLanguage {
233            two_letter_code: "is",
234            country_code: "",
235            three_letter_code: "isl",
236            language: Language::Icelandic,
237        },
238        IsoLanguage {
239            two_letter_code: "zh",
240            country_code: "cn",
241            three_letter_code: "zho",
242            language: Language::Chinese,
243        },
244        IsoLanguage {
245            two_letter_code: "ru",
246            country_code: "",
247            three_letter_code: "rus",
248            language: Language::Russian,
249        },
250        IsoLanguage {
251            two_letter_code: "pl",
252            country_code: "",
253            three_letter_code: "pol",
254            language: Language::Polish,
255        },
256        IsoLanguage {
257            two_letter_code: "vi",
258            country_code: "",
259            three_letter_code: "vie",
260            language: Language::Vietnamese,
261        },
262        IsoLanguage {
263            two_letter_code: "sv",
264            country_code: "",
265            three_letter_code: "swe",
266            language: Language::Swedish,
267        },
268        IsoLanguage {
269            two_letter_code: "no",
270            country_code: "",
271            three_letter_code: "nor",
272            language: Language::Norwegian,
273        },
274        // Norwegian Bokmal: 2-letter "nb" maps to the same Language::Norwegian.
275        IsoLanguage {
276            two_letter_code: "nb",
277            country_code: "",
278            three_letter_code: "nob",
279            language: Language::Norwegian,
280        },
281        IsoLanguage {
282            two_letter_code: "fi",
283            country_code: "",
284            three_letter_code: "fin",
285            language: Language::Finnish,
286        },
287        IsoLanguage {
288            two_letter_code: "tr",
289            country_code: "",
290            three_letter_code: "tur",
291            language: Language::Turkish,
292        },
293        IsoLanguage {
294            two_letter_code: "pt",
295            country_code: "pt",
296            three_letter_code: "por",
297            language: Language::Portuguese,
298        },
299        // Flemish shares "nl" + "nld" with Dutch; Dutch precedes so a bare "nl" / "nld" resolves to Dutch.
300        IsoLanguage {
301            two_letter_code: "nl",
302            country_code: "",
303            three_letter_code: "nld",
304            language: Language::Flemish,
305        },
306        IsoLanguage {
307            two_letter_code: "el",
308            country_code: "",
309            three_letter_code: "ell",
310            language: Language::Greek,
311        },
312        IsoLanguage {
313            two_letter_code: "ko",
314            country_code: "",
315            three_letter_code: "kor",
316            language: Language::Korean,
317        },
318        IsoLanguage {
319            two_letter_code: "hu",
320            country_code: "",
321            three_letter_code: "hun",
322            language: Language::Hungarian,
323        },
324        IsoLanguage {
325            two_letter_code: "he",
326            country_code: "",
327            three_letter_code: "heb",
328            language: Language::Hebrew,
329        },
330        IsoLanguage {
331            two_letter_code: "lt",
332            country_code: "",
333            three_letter_code: "lit",
334            language: Language::Lithuanian,
335        },
336        IsoLanguage {
337            two_letter_code: "cs",
338            country_code: "",
339            three_letter_code: "ces",
340            language: Language::Czech,
341        },
342        IsoLanguage {
343            two_letter_code: "ar",
344            country_code: "",
345            three_letter_code: "ara",
346            language: Language::Arabic,
347        },
348        IsoLanguage {
349            two_letter_code: "hi",
350            country_code: "",
351            three_letter_code: "hin",
352            language: Language::Hindi,
353        },
354        IsoLanguage {
355            two_letter_code: "bg",
356            country_code: "",
357            three_letter_code: "bul",
358            language: Language::Bulgarian,
359        },
360        IsoLanguage {
361            two_letter_code: "ml",
362            country_code: "",
363            three_letter_code: "mal",
364            language: Language::Malayalam,
365        },
366        IsoLanguage {
367            two_letter_code: "uk",
368            country_code: "",
369            three_letter_code: "ukr",
370            language: Language::Ukrainian,
371        },
372        IsoLanguage {
373            two_letter_code: "sk",
374            country_code: "",
375            three_letter_code: "slk",
376            language: Language::Slovak,
377        },
378        IsoLanguage {
379            two_letter_code: "th",
380            country_code: "th",
381            three_letter_code: "tha",
382            language: Language::Thai,
383        },
384        IsoLanguage {
385            two_letter_code: "pt",
386            country_code: "br",
387            three_letter_code: "por",
388            language: Language::PortugueseBrazil,
389        },
390        IsoLanguage {
391            two_letter_code: "es",
392            country_code: "mx",
393            three_letter_code: "spa",
394            language: Language::SpanishLatino,
395        },
396        IsoLanguage {
397            two_letter_code: "ro",
398            country_code: "",
399            three_letter_code: "ron",
400            language: Language::Romanian,
401        },
402        IsoLanguage {
403            two_letter_code: "lv",
404            country_code: "",
405            three_letter_code: "lav",
406            language: Language::Latvian,
407        },
408        IsoLanguage {
409            two_letter_code: "fa",
410            country_code: "",
411            three_letter_code: "fas",
412            language: Language::Persian,
413        },
414        IsoLanguage {
415            two_letter_code: "ca",
416            country_code: "",
417            three_letter_code: "cat",
418            language: Language::Catalan,
419        },
420        IsoLanguage {
421            two_letter_code: "hr",
422            country_code: "",
423            three_letter_code: "hrv",
424            language: Language::Croatian,
425        },
426        IsoLanguage {
427            two_letter_code: "sr",
428            country_code: "",
429            three_letter_code: "srp",
430            language: Language::Serbian,
431        },
432        IsoLanguage {
433            two_letter_code: "bs",
434            country_code: "",
435            three_letter_code: "bos",
436            language: Language::Bosnian,
437        },
438        IsoLanguage {
439            two_letter_code: "et",
440            country_code: "",
441            three_letter_code: "est",
442            language: Language::Estonian,
443        },
444        IsoLanguage {
445            two_letter_code: "ta",
446            country_code: "",
447            three_letter_code: "tam",
448            language: Language::Tamil,
449        },
450        IsoLanguage {
451            two_letter_code: "id",
452            country_code: "",
453            three_letter_code: "ind",
454            language: Language::Indonesian,
455        },
456        IsoLanguage {
457            two_letter_code: "mk",
458            country_code: "",
459            three_letter_code: "mkd",
460            language: Language::Macedonian,
461        },
462        IsoLanguage {
463            two_letter_code: "sl",
464            country_code: "",
465            three_letter_code: "slv",
466            language: Language::Slovenian,
467        },
468    ];
469
470    /// Sonarr's `AlternateIsoCodeMappings` dictionary: shortcut three-
471    /// to-five-letter codes that don't follow the ISO-639 / ISO-3166
472    /// scheme and map directly to a `Language`. Looked up against the
473    /// raw (post-lowercase) input, before the 3-letter ISO-639-2/T
474    /// fallback in `find`.
475    const ALTERNATE: &[(&str, Language)] = &[
476        ("cze", Language::Czech),
477        ("dut", Language::Dutch),
478        ("mac", Language::Macedonian),
479        ("rum", Language::Romanian),
480        ("yue", Language::Chinese),
481        ("zhtw", Language::Chinese),
482    ];
483
484    /// Sonarr's `Iso639BTMap` (from `Organizer/FileNameBuilder.cs`):
485    /// ISO-639-2/B (bibliographic) to ISO-639-2/T (terminological).
486    /// Sonarr applies this before the 3-letter lookup so a B-form
487    /// release tag like `fre` finds French via its T-form `fra`. Only
488    /// the entries Sonarr actually ships are here. None of the keys
489    /// collide with our `ALTERNATE` table because that fires earlier.
490    const B_TO_T_MAP: &[(&str, &str)] = &[
491        ("alb", "sqi"),
492        ("arm", "hye"),
493        ("baq", "eus"),
494        ("bur", "mya"),
495        ("chi", "zho"),
496        ("cze", "ces"),
497        ("dut", "nld"),
498        ("fre", "fra"),
499        ("geo", "kat"),
500        ("ger", "deu"),
501        ("gre", "ell"),
502        ("ice", "isl"),
503        ("mac", "mkd"),
504        ("mao", "mri"),
505        ("may", "msa"),
506        ("per", "fas"),
507        ("rum", "ron"),
508        ("slo", "slk"),
509        ("tib", "bod"),
510        ("wel", "cym"),
511    ];
512
513    /// Resolve an ISO-639 / locale code to a `Language`. Mirrors
514    /// Sonarr's `IsoLanguages.Find` algorithm:
515    ///
516    /// 1. Lowercase + split on `-`. The part before `-` is `langCode`.
517    /// 2. If `langCode` is 2 letters: filter `ALL` by `two_letter_code`.
518    ///    With a country part, prefer rows whose `country_code` matches;
519    ///    if none match, narrow to rows with empty `country_code`. With
520    ///    no country part, return the first matching row.
521    /// 3. If the full lowercased input is in `ALTERNATE`, return that.
522    /// 4. If `langCode` is 3 letters: apply `B_TO_T_MAP` if present,
523    ///    then look up by `three_letter_code`.
524    /// 5. Anything else (4+, or 0 letters): `None`.
525    ///
526    /// The shortcut return type is `Option<Language>` rather than
527    /// `Option<IsoLanguage>` because every current consumer wants the
528    /// `Language` only. `find_iso` is the richer entrypoint.
529    ///
530    /// # Case-sensitivity note
531    ///
532    /// This function lowercases input before consulting the alternate map. C#'s
533    /// `IsoLanguages.Find` (Parser/IsoLanguages.cs:91) is case-sensitive on the
534    /// alternate-map lookup. All in-tree C# call sites pre-lowercase the input,
535    /// so the divergence is unreachable in practice; the more conservative Rust
536    /// behaviour widens the function's input space without affecting outputs
537    /// for any existing caller.
538    pub fn find(code: &str) -> Option<Language> {
539        find_iso(code).map(|iso| iso.language)
540    }
541
542    /// Same algorithm as [`find`] but returns the full `IsoLanguage`
543    /// record (so callers can read the original ISO codes back, e.g.
544    /// for reverse-mapping `PortugueseBrazil` to `pt-br`).
545    pub fn find_iso(code: &str) -> Option<IsoLanguage> {
546        let lowered = code.to_ascii_lowercase();
547        let (lang_code, country_code) = match lowered.split_once('-') {
548            Some((l, c)) => (l, Some(c)),
549            None => (lowered.as_str(), None),
550        };
551
552        match lang_code.len() {
553            2 => find_two_letter(lang_code, country_code),
554            3 => {
555                // Alternate map matches the full lowered code, not
556                // just `lang_code`. Sonarr does the alternate lookup
557                // before the 3-letter ISO-639-2 lookup.
558                if let Some(lang) = alternate_lookup(&lowered) {
559                    return canonical_row(lang);
560                }
561                find_three_letter(lang_code)
562            }
563            // `len() != 2 && != 3` still has to consult the alternate
564            // map: `zhtw` is 4 letters but lives there.
565            _ => alternate_lookup(&lowered).and_then(canonical_row),
566        }
567    }
568
569    fn find_two_letter(lang_code: &str, country_code: Option<&str>) -> Option<IsoLanguage> {
570        let matches: Vec<&IsoLanguage> = ALL
571            .iter()
572            .filter(|l| l.two_letter_code == lang_code)
573            .collect();
574
575        if matches.is_empty() {
576            return None;
577        }
578
579        match country_code {
580            None => matches.first().copied().copied(),
581            Some(country) => {
582                // Prefer rows whose country code matches.
583                let any_country_match = matches.iter().any(|l| l.country_code == country);
584                if any_country_match {
585                    matches
586                        .iter()
587                        .find(|l| l.country_code == country)
588                        .copied()
589                        .copied()
590                } else {
591                    // Fall back to rows with empty country code.
592                    matches
593                        .iter()
594                        .find(|l| l.country_code.is_empty())
595                        .copied()
596                        .copied()
597                }
598            }
599        }
600    }
601
602    fn find_three_letter(lang_code: &str) -> Option<IsoLanguage> {
603        let mapped = B_TO_T_MAP
604            .iter()
605            .find_map(|(b, t)| (*b == lang_code).then_some(*t))
606            .unwrap_or(lang_code);
607        ALL.iter().find(|l| l.three_letter_code == mapped).copied()
608    }
609
610    fn alternate_lookup(lowered: &str) -> Option<Language> {
611        ALTERNATE
612            .iter()
613            .find_map(|(code, lang)| (*code == lowered).then_some(*lang))
614    }
615
616    /// Find the canonical `IsoLanguage` row in `ALL` for a given
617    /// `Language`. Mirrors C# `IsoLanguages.Get` (Parser/IsoLanguages.cs:109-112)
618    /// which is `All.FirstOrDefault(l => l.Language == language)`.
619    ///
620    /// Used by [`find_iso`] on the alternate-map path: the alternate
621    /// dict only carries the `Language`, so we have to walk back to
622    /// `ALL` to recover the canonical 2/3-letter codes.
623    ///
624    /// Returns `None` if the language has no entry in `ALL` — Sonarr's
625    /// equivalent returns a default `IsoLanguage()` in that case but
626    /// this branch is unreachable in practice. Several `B_TO_T_MAP`
627    /// targets (`sqi`/`hye`/`eus`/`mya`/`kat`/`mri`/`msa`/`bod`/`cym`)
628    /// do not appear in `ALL`, but those flow through `find_three_letter`,
629    /// not the alternate path; every entry in `ALTERNATE` points at a
630    /// `Language` that has at least one row in `ALL`.
631    fn canonical_row(language: Language) -> Option<IsoLanguage> {
632        ALL.iter().find(|l| l.language == language).copied()
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    // --- Plan-required tests ---------------------------------------
641
642    #[test]
643    fn language_constants_have_expected_ids() {
644        assert_eq!(Language::Unknown as i32, 0);
645        assert_eq!(Language::English as i32, 1);
646        assert_eq!(Language::SpanishLatino as i32, 34);
647        assert_eq!(Language::Slovenian as i32, 46);
648        assert_eq!(Language::Original as i32, -2);
649    }
650
651    #[test]
652    fn iso_languages_finds_eng() {
653        assert_eq!(iso_languages::find("eng"), Some(Language::English));
654    }
655
656    #[test]
657    fn iso_languages_finds_fre() {
658        // "fre" is the ISO-639-2/B form of French; B-to-T map sends it to "fra".
659        assert_eq!(iso_languages::find("fre"), Some(Language::French));
660    }
661
662    #[test]
663    fn iso_languages_unknown_for_garbage() {
664        assert_eq!(iso_languages::find("zzz"), None);
665    }
666
667    // --- C# IsoLanguagesFixture parity -----------------------------
668
669    #[test]
670    fn english_resolves_via_two_letter_three_letter_and_locale() {
671        for code in ["en", "eng", "en-US", "en-GB"] {
672            assert_eq!(
673                iso_languages::find(code),
674                Some(Language::English),
675                "code = {code}"
676            );
677        }
678    }
679
680    #[test]
681    fn unknown_or_invalid_codes_return_none() {
682        // Mirrors C# IsoLanguagesFixture.unknown_or_invalid_code_should_return_null.
683        for code in ["enus", "enusa", "wo", "fr-CA"] {
684            assert_eq!(iso_languages::find(code), None, "code = {code}");
685        }
686    }
687
688    #[test]
689    fn portuguese_resolves_via_two_letter_three_letter_and_locale() {
690        for code in ["pt", "por", "pt-PT"] {
691            assert_eq!(
692                iso_languages::find(code),
693                Some(Language::Portuguese),
694                "code = {code}"
695            );
696        }
697    }
698
699    #[test]
700    fn de_au_returns_none_because_german_requires_de_country_code() {
701        // German entry has CountryCode="de"; no fallback row with empty
702        // country code exists for "de", so de-AU yields None.
703        assert_eq!(iso_languages::find("de-AU"), None);
704    }
705
706    #[test]
707    fn cze_resolves_via_alternate_iso_code_mapping() {
708        assert_eq!(iso_languages::find("cze"), Some(Language::Czech));
709    }
710
711    // --- Additional coverage of the algorithm ----------------------
712
713    #[test]
714    fn pt_br_resolves_to_portuguese_brazil() {
715        assert_eq!(
716            iso_languages::find("pt-br"),
717            Some(Language::PortugueseBrazil)
718        );
719        // Case-insensitive on both halves.
720        assert_eq!(
721            iso_languages::find("pt-BR"),
722            Some(Language::PortugueseBrazil)
723        );
724    }
725
726    #[test]
727    fn es_mx_resolves_to_spanish_latino_but_bare_es_to_spanish() {
728        assert_eq!(iso_languages::find("es"), Some(Language::Spanish));
729        assert_eq!(iso_languages::find("es-mx"), Some(Language::SpanishLatino));
730    }
731
732    #[test]
733    fn bare_nl_resolves_to_dutch_not_flemish() {
734        // Both Dutch and Flemish map to ("nl", "", "nld"); Dutch is
735        // listed first in ALL, so FirstOrDefault returns Dutch.
736        assert_eq!(iso_languages::find("nl"), Some(Language::Dutch));
737        assert_eq!(iso_languages::find("nld"), Some(Language::Dutch));
738    }
739
740    #[test]
741    fn b_to_t_map_resolves_old_ger_form_to_german() {
742        assert_eq!(iso_languages::find("ger"), Some(Language::German));
743        assert_eq!(iso_languages::find("deu"), Some(Language::German));
744    }
745
746    #[test]
747    fn alternate_zhtw_resolves_to_chinese() {
748        // Four-letter alternate code; only path that hits the >3-letter
749        // arm of the algorithm.
750        assert_eq!(iso_languages::find("zhtw"), Some(Language::Chinese));
751    }
752
753    #[test]
754    fn nb_two_letter_resolves_to_norwegian() {
755        // Norwegian Bokmal entry wires to the same Language::Norwegian.
756        assert_eq!(iso_languages::find("nb"), Some(Language::Norwegian));
757        assert_eq!(iso_languages::find("nob"), Some(Language::Norwegian));
758    }
759
760    #[test]
761    fn case_is_normalised() {
762        assert_eq!(iso_languages::find("ENG"), Some(Language::English));
763        assert_eq!(iso_languages::find("Fr"), Some(Language::French));
764    }
765
766    #[test]
767    fn empty_and_oversized_codes_return_none() {
768        assert_eq!(iso_languages::find(""), None);
769        assert_eq!(iso_languages::find("english"), None);
770    }
771
772    #[test]
773    fn language_default_is_unknown() {
774        assert_eq!(Language::default(), Language::Unknown);
775    }
776
777    #[test]
778    fn language_name_round_trips_for_brazil_and_latino() {
779        assert_eq!(Language::PortugueseBrazil.name(), "Portuguese (Brazil)");
780        assert_eq!(Language::SpanishLatino.name(), "Spanish (Latino)");
781        assert_eq!(Language::Original.name(), "Original");
782    }
783
784    #[test]
785    fn find_iso_returns_full_record_for_canonical_codes() {
786        let iso = iso_languages::find_iso("pt-br").expect("pt-br must resolve");
787        assert_eq!(iso.language, Language::PortugueseBrazil);
788        assert_eq!(iso.two_letter_code, "pt");
789        assert_eq!(iso.country_code, "br");
790        assert_eq!(iso.three_letter_code, "por");
791    }
792
793    #[test]
794    fn find_iso_alternate_hit_returns_canonical_row_not_synthetic() {
795        // C# IsoLanguages.Find calls Get(language) on alternate hits,
796        // which canonicalizes to the ALL-table row. The Rust port previously
797        // synthesized empty codes; this test guards the fix.
798        let cze = iso_languages::find_iso("cze").expect("alternate hit");
799        assert_eq!(cze.language, Language::Czech);
800        assert_eq!(cze.three_letter_code, "ces"); // canonical form, not empty
801        assert_eq!(cze.two_letter_code, "cs"); // canonical form, not empty
802    }
803
804    #[test]
805    fn from_language_to_i32_preserves_id() {
806        assert_eq!(i32::from(Language::English), 1);
807        assert_eq!(i32::from(Language::Original), -2);
808        assert_eq!(i32::from(Language::Slovenian), 46);
809    }
810
811    /// Sonarr's `All` list has 48 entries: `Unknown = 0`, 46 contiguous
812    /// positive variants (`English = 1` through `Slovenian = 46`), and
813    /// the `Original = -2` sentinel. The m50 plan-spec stated "47", but
814    /// counting the C# `All` initializer in `Language.cs:122-178`
815    /// yields 48; standing rule #1 says C# fidelity wins.
816    ///
817    /// This test catches accidental drift if anyone reorders or drops
818    /// a variant.
819    #[test]
820    fn enum_has_exactly_48_variants_with_contiguous_ids() {
821        // We can't introspect the enum at runtime, so list every
822        // variant explicitly. A new variant added without updating
823        // this test is fine; a removed or renumbered variant trips it.
824        let all: &[Language] = &[
825            Language::Unknown,
826            Language::English,
827            Language::French,
828            Language::Spanish,
829            Language::German,
830            Language::Italian,
831            Language::Danish,
832            Language::Dutch,
833            Language::Japanese,
834            Language::Icelandic,
835            Language::Chinese,
836            Language::Russian,
837            Language::Polish,
838            Language::Vietnamese,
839            Language::Swedish,
840            Language::Norwegian,
841            Language::Finnish,
842            Language::Turkish,
843            Language::Portuguese,
844            Language::Flemish,
845            Language::Greek,
846            Language::Korean,
847            Language::Hungarian,
848            Language::Hebrew,
849            Language::Lithuanian,
850            Language::Czech,
851            Language::Arabic,
852            Language::Hindi,
853            Language::Bulgarian,
854            Language::Malayalam,
855            Language::Ukrainian,
856            Language::Slovak,
857            Language::Thai,
858            Language::PortugueseBrazil,
859            Language::SpanishLatino,
860            Language::Romanian,
861            Language::Latvian,
862            Language::Persian,
863            Language::Catalan,
864            Language::Croatian,
865            Language::Serbian,
866            Language::Bosnian,
867            Language::Estonian,
868            Language::Tamil,
869            Language::Indonesian,
870            Language::Macedonian,
871            Language::Slovenian,
872            Language::Original,
873        ];
874        assert_eq!(all.len(), 48);
875        // Indices 0..=46 must equal the contiguous IDs Unknown..Slovenian.
876        // Index 47 is Original (-2).
877        for (idx, lang) in all.iter().enumerate() {
878            if idx < 47 {
879                assert_eq!(*lang as i32, idx as i32, "variant at index {idx}");
880            } else {
881                assert_eq!(*lang as i32, -2, "Original sentinel last");
882            }
883        }
884    }
885}