1use std::collections::BTreeMap;
25
26use fluent_bundle::{
27 FluentArgs, FluentError, FluentResource, FluentValue, concurrent::FluentBundle,
28};
29use gatekeep::{DenialReason, DenyShape, Locale, ReasonCatalog, ReasonValue};
30use thiserror::Error;
31use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
32
33const DEFAULT_HIDDEN_MESSAGE: &str = "not found";
34const DEFAULT_HIDDEN_MESSAGE_ID: &str = "not-found";
35
36pub struct FluentCatalog {
38 bundles: BTreeMap<String, FluentBundle<FluentResource>>,
39 fallback_locale: Option<String>,
40 hidden_message_id: String,
41 hidden_fallback: String,
42}
43
44impl Default for FluentCatalog {
45 fn default() -> Self {
46 Self::new()
47 }
48}
49
50impl FluentCatalog {
51 #[must_use]
53 pub fn new() -> Self {
54 Self {
55 bundles: BTreeMap::new(),
56 fallback_locale: None,
57 hidden_message_id: DEFAULT_HIDDEN_MESSAGE_ID.to_owned(),
58 hidden_fallback: DEFAULT_HIDDEN_MESSAGE.to_owned(),
59 }
60 }
61
62 pub fn set_fallback_locale(
69 &mut self,
70 locale: impl AsRef<str>,
71 ) -> Result<(), FluentCatalogError> {
72 let locale = parse_locale(locale.as_ref())?;
73 self.fallback_locale = Some(locale.to_string());
74 Ok(())
75 }
76
77 pub fn with_fallback_locale(
84 mut self,
85 locale: impl AsRef<str>,
86 ) -> Result<Self, FluentCatalogError> {
87 self.set_fallback_locale(locale)?;
88 Ok(self)
89 }
90
91 pub fn set_hidden_message(
103 &mut self,
104 message_id: impl Into<String>,
105 fallback: impl Into<String>,
106 ) -> Result<(), FluentCatalogError> {
107 self.hidden_message_id = validate_message_id(message_id.into())?;
108 self.hidden_fallback = validate_hidden_fallback(fallback.into())?;
109 Ok(())
110 }
111
112 pub fn with_hidden_message(
119 mut self,
120 message_id: impl Into<String>,
121 fallback: impl Into<String>,
122 ) -> Result<Self, FluentCatalogError> {
123 self.set_hidden_message(message_id, fallback)?;
124 Ok(self)
125 }
126
127 pub fn add_resource(
137 &mut self,
138 locale: impl AsRef<str>,
139 source: impl Into<String>,
140 ) -> Result<(), FluentCatalogError> {
141 let locale = parse_locale(locale.as_ref())?;
142 let locale_key = locale.to_string();
143 let resource = parse_resource(&locale_key, source.into())?;
144 let bundle = self
145 .bundles
146 .entry(locale_key.clone())
147 .or_insert_with(|| FluentBundle::new_concurrent(vec![locale]));
148 bundle
149 .add_resource(resource)
150 .map_err(|errors| FluentCatalogError::Resource {
151 locale: locale_key,
152 errors: display_errors(errors),
153 })
154 }
155
156 pub fn with_resource(
162 mut self,
163 locale: impl AsRef<str>,
164 source: impl Into<String>,
165 ) -> Result<Self, FluentCatalogError> {
166 self.add_resource(locale, source)?;
167 Ok(self)
168 }
169
170 #[must_use]
173 pub fn render_reason(&self, reason: &DenialReason, locale: &Locale) -> String {
174 if reason.shape == DenyShape::Hidden {
175 return self.render_hidden(locale);
176 }
177 self.try_render_reason(reason, locale)
178 .unwrap_or_else(|| reason.code.as_str().to_owned())
179 }
180
181 #[must_use]
187 pub fn try_render_reason(&self, reason: &DenialReason, locale: &Locale) -> Option<String> {
188 if reason.shape == DenyShape::Hidden {
189 return self.try_render_hidden(locale);
190 }
191
192 for locale_key in self.locale_candidates(locale) {
193 if let Some(rendered) = self.render_from_bundle(reason, &locale_key) {
194 return Some(rendered);
195 }
196 }
197 None
198 }
199
200 fn render_hidden(&self, locale: &Locale) -> String {
201 self.try_render_hidden(locale)
202 .unwrap_or_else(|| self.hidden_fallback.clone())
203 }
204
205 fn try_render_hidden(&self, locale: &Locale) -> Option<String> {
206 for locale_key in self.locale_candidates(locale) {
207 if let Some(rendered) = self.render_message(&self.hidden_message_id, None, &locale_key)
208 {
209 return Some(rendered);
210 }
211 }
212 None
213 }
214
215 fn locale_candidates(&self, locale: &Locale) -> Vec<String> {
216 let mut candidates = Vec::new();
217 let fallback = self.fallback_locale.as_deref();
218 let ordered_candidates = [
219 Some(locale.as_str()),
220 locale
221 .as_str()
222 .split_once('-')
223 .map(|(language, _)| language),
224 fallback,
225 fallback.and_then(|value| value.split_once('-').map(|(language, _)| language)),
226 ];
227 for candidate in ordered_candidates.into_iter().flatten() {
228 push_locale_candidate(&mut candidates, candidate);
229 }
230
231 candidates
232 }
233
234 fn render_from_bundle(&self, reason: &DenialReason, locale_key: &str) -> Option<String> {
235 let args = fluent_args(reason);
236 self.render_message(reason.code.as_str(), Some(&args), locale_key)
237 }
238
239 fn render_message(
240 &self,
241 message_id: &str,
242 args: Option<&FluentArgs<'_>>,
243 locale_key: &str,
244 ) -> Option<String> {
245 let bundle = self.bundles.get(locale_key)?;
246 let message = bundle.get_message(message_id)?;
247 let pattern = message.value()?;
248 let mut errors = Vec::new();
249 let rendered = bundle.format_pattern(pattern, args, &mut errors);
250 errors.is_empty().then(|| rendered.into_owned())
251 }
252}
253
254impl ReasonCatalog for FluentCatalog {
255 fn render(&self, reason: &DenialReason, locale: &Locale) -> String {
256 self.render_reason(reason, locale)
257 }
258}
259
260#[derive(Debug, Error, PartialEq)]
262pub enum FluentCatalogError {
263 #[error("invalid fluent locale {locale}: {source}")]
265 InvalidLocale {
266 locale: String,
268 source: LanguageIdentifierError,
270 },
271 #[error("failed to parse fluent resource for {locale}: {}", errors.join("; "))]
273 Parse {
274 locale: String,
276 errors: Vec<String>,
278 },
279 #[error("failed to add fluent resource for {locale}: {}", errors.join("; "))]
281 Resource {
282 locale: String,
284 errors: Vec<String>,
286 },
287 #[error("{field} must not be empty")]
289 EmptyMessageId {
290 field: &'static str,
292 },
293 #[error("{field} must not be empty")]
295 EmptyFallbackMessage {
296 field: &'static str,
298 },
299}
300
301fn parse_locale(locale: &str) -> Result<LanguageIdentifier, FluentCatalogError> {
302 locale
303 .parse::<LanguageIdentifier>()
304 .map_err(|source| FluentCatalogError::InvalidLocale {
305 locale: locale.to_owned(),
306 source,
307 })
308}
309
310fn parse_resource(locale: &str, source: String) -> Result<FluentResource, FluentCatalogError> {
311 FluentResource::try_new(source).map_err(|(_resource, errors)| FluentCatalogError::Parse {
312 locale: locale.to_owned(),
313 errors: errors.into_iter().map(|error| error.to_string()).collect(),
314 })
315}
316
317fn display_errors(errors: Vec<FluentError>) -> Vec<String> {
318 errors.into_iter().map(|error| error.to_string()).collect()
319}
320
321fn validate_message_id(value: String) -> Result<String, FluentCatalogError> {
322 if value.trim().is_empty() {
323 Err(FluentCatalogError::EmptyMessageId {
324 field: "hidden message id",
325 })
326 } else {
327 Ok(value)
328 }
329}
330
331fn validate_hidden_fallback(value: String) -> Result<String, FluentCatalogError> {
332 if value.trim().is_empty() {
333 Err(FluentCatalogError::EmptyFallbackMessage {
334 field: "hidden fallback message",
335 })
336 } else {
337 Ok(value)
338 }
339}
340
341fn push_locale_candidate(candidates: &mut Vec<String>, locale: &str) {
342 if let Ok(locale) = locale.parse::<LanguageIdentifier>() {
343 let candidate = locale.to_string();
344 if !candidates.contains(&candidate) {
345 candidates.push(candidate);
346 }
347 }
348}
349
350fn fluent_args(reason: &DenialReason) -> FluentArgs<'static> {
351 let mut args = FluentArgs::with_capacity(reason.params.len());
352 for (key, value) in &reason.params {
353 args.set(key.as_str().to_owned(), fluent_value(value));
354 }
355 args
356}
357
358fn fluent_value(value: &ReasonValue) -> FluentValue<'static> {
359 match value {
360 ReasonValue::Str(value) => FluentValue::from(value.clone()),
361 ReasonValue::Int(value) => FluentValue::from(*value),
362 ReasonValue::Fact(fact) => FluentValue::from(fact.as_str().to_owned()),
363 ReasonValue::Outcome(value) => json_value(value),
364 }
365}
366
367fn json_value(value: &serde_json::Value) -> FluentValue<'static> {
368 match value {
369 serde_json::Value::Null => FluentValue::from("null"),
370 serde_json::Value::Bool(value) => FluentValue::from(value.to_string()),
371 serde_json::Value::Number(value) => number_value(value),
372 serde_json::Value::String(value) => FluentValue::from(value.clone()),
373 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
374 FluentValue::from(value.to_string())
375 }
376 }
377}
378
379fn number_value(value: &serde_json::Number) -> FluentValue<'static> {
380 value
381 .as_i64()
382 .map_or_else(|| unsigned_or_float_value(value), FluentValue::from)
383}
384
385fn unsigned_or_float_value(value: &serde_json::Number) -> FluentValue<'static> {
386 value.as_u64().map_or_else(
387 || float_value(value),
388 |value| {
389 i64::try_from(value)
390 .map_or_else(|_| FluentValue::from(value.to_string()), FluentValue::from)
391 },
392 )
393}
394
395fn float_value(value: &serde_json::Number) -> FluentValue<'static> {
396 value
397 .as_f64()
398 .map_or_else(|| FluentValue::from(value.to_string()), FluentValue::from)
399}