1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use use_locale_tag::{LocaleTagParts, normalize_locale_tag, parse_locale_tag_parts};
5
6#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct LocalePreference {
9 pub locale: String,
10 pub priority: usize,
11}
12
13impl LocalePreference {
14 #[must_use]
16 pub fn new(locale: &str, priority: usize) -> Option<Self> {
17 normalize_locale_tag(locale).map(|locale| Self { locale, priority })
18 }
19}
20
21#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct LocaleMatch {
24 pub requested: String,
25 pub available: String,
26 pub fallback_index: usize,
27}
28
29impl LocaleMatch {
30 #[must_use]
32 pub const fn is_exact(&self) -> bool {
33 self.fallback_index == 0
34 }
35}
36
37#[must_use]
39pub fn fallback_chain(input: &str) -> Vec<String> {
40 let Some(mut parts) = parse_locale_tag_parts(input) else {
41 return Vec::new();
42 };
43
44 let mut chain = Vec::new();
45 push_unique_tag(&mut chain, &parts);
46
47 if parts.private_use.take().is_some() {
48 push_unique_tag(&mut chain, &parts);
49 }
50
51 while parts.extensions.pop().is_some() {
52 push_unique_tag(&mut chain, &parts);
53 }
54
55 while parts.variants.pop().is_some() {
56 push_unique_tag(&mut chain, &parts);
57 }
58
59 if parts.region.take().is_some() {
60 push_unique_tag(&mut chain, &parts);
61 }
62
63 if parts.script.take().is_some() {
64 push_unique_tag(&mut chain, &parts);
65 }
66
67 chain
68}
69
70#[must_use]
72pub fn best_locale_match<I, S>(requested: &str, available: I) -> Option<LocaleMatch>
73where
74 I: IntoIterator<Item = S>,
75 S: AsRef<str>,
76{
77 let chain = fallback_chain(requested);
78 let requested = chain.first()?.clone();
79 let available = available
80 .into_iter()
81 .filter_map(|locale| normalize_locale_tag(locale.as_ref()))
82 .collect::<Vec<_>>();
83
84 for (fallback_index, candidate) in chain.iter().enumerate() {
85 if let Some(matched) = available.iter().find(|locale| *locale == candidate) {
86 return Some(LocaleMatch {
87 requested,
88 available: matched.clone(),
89 fallback_index,
90 });
91 }
92 }
93
94 None
95}
96
97fn push_unique_tag(chain: &mut Vec<String>, parts: &LocaleTagParts) {
98 let candidate = parts.to_tag_string();
99 if chain.last() != Some(&candidate) {
100 chain.push(candidate);
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::{LocalePreference, best_locale_match, fallback_chain};
107
108 #[test]
109 fn builds_expected_fallback_chains() {
110 assert_eq!(fallback_chain("en-US"), vec!["en-US", "en"]);
111 assert_eq!(
112 fallback_chain("zh-Hant-TW"),
113 vec!["zh-Hant-TW", "zh-Hant", "zh"]
114 );
115 }
116
117 #[test]
118 fn removes_suffixes_before_core_subtags() {
119 assert_eq!(
120 fallback_chain("en-US-oxendict-u-ca-gregory-x-app"),
121 vec![
122 "en-US-oxendict-u-ca-gregory-x-app",
123 "en-US-oxendict-u-ca-gregory",
124 "en-US-oxendict",
125 "en-US",
126 "en",
127 ]
128 );
129 }
130
131 #[test]
132 fn best_match_uses_fallback_order() {
133 let matched = best_locale_match("en-US", ["en", "fr"]).unwrap();
134
135 assert_eq!(matched.requested, "en-US");
136 assert_eq!(matched.available, "en");
137 assert_eq!(matched.fallback_index, 1);
138 assert!(!matched.is_exact());
139 }
140
141 #[test]
142 fn exact_matches_win() {
143 let matched = best_locale_match("en-US", ["en", "en-us"]).unwrap();
144
145 assert_eq!(matched.available, "en-US");
146 assert!(matched.is_exact());
147 }
148
149 #[test]
150 fn invalid_requested_locale_has_no_match() {
151 assert!(best_locale_match("not_a_locale", ["en"]).is_none());
152 assert!(fallback_chain("not_a_locale").is_empty());
153 }
154
155 #[test]
156 fn builds_normalized_preferences() {
157 let preference = LocalePreference::new("ZH-hant-tw", 0).unwrap();
158
159 assert_eq!(preference.locale, "zh-Hant-TW");
160 assert_eq!(preference.priority, 0);
161 }
162}