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 #[must_use]
68 pub fn missing_reasons<'a, O>(
69 &self,
70 policy: &'a gatekeep::PreparedPolicy<O>,
71 locale: &Locale,
72 ) -> Vec<&'a str> {
73 let bundle = self.bundles.get(locale.as_str());
74 policy
75 .inspect()
76 .reasons
77 .into_iter()
78 .filter(|reason| {
79 bundle
80 .and_then(|bundle| bundle.get_message(reason))
81 .and_then(|message| message.value())
82 .is_none()
83 })
84 .collect()
85 }
86
87 pub fn set_fallback_locale(
94 &mut self,
95 locale: impl AsRef<str>,
96 ) -> Result<(), FluentCatalogError> {
97 let locale = parse_locale(locale.as_ref())?;
98 self.fallback_locale = Some(locale.to_string());
99 Ok(())
100 }
101
102 pub fn with_fallback_locale(
109 mut self,
110 locale: impl AsRef<str>,
111 ) -> Result<Self, FluentCatalogError> {
112 self.set_fallback_locale(locale)?;
113 Ok(self)
114 }
115
116 pub fn set_hidden_message(
128 &mut self,
129 message_id: impl Into<String>,
130 fallback: impl Into<String>,
131 ) -> Result<(), FluentCatalogError> {
132 self.hidden_message_id = validate_message_id(message_id.into())?;
133 self.hidden_fallback = validate_hidden_fallback(fallback.into())?;
134 Ok(())
135 }
136
137 pub fn with_hidden_message(
144 mut self,
145 message_id: impl Into<String>,
146 fallback: impl Into<String>,
147 ) -> Result<Self, FluentCatalogError> {
148 self.set_hidden_message(message_id, fallback)?;
149 Ok(self)
150 }
151
152 pub fn add_resource(
162 &mut self,
163 locale: impl AsRef<str>,
164 source: impl Into<String>,
165 ) -> Result<(), FluentCatalogError> {
166 let locale = parse_locale(locale.as_ref())?;
167 let locale_key = locale.to_string();
168 let resource = parse_resource(&locale_key, source.into())?;
169 let bundle = self
170 .bundles
171 .entry(locale_key.clone())
172 .or_insert_with(|| FluentBundle::new_concurrent(vec![locale]));
173 bundle
174 .add_resource(resource)
175 .map_err(|errors| FluentCatalogError::Resource {
176 locale: locale_key,
177 errors: display_errors(errors),
178 })
179 }
180
181 pub fn with_resource(
187 mut self,
188 locale: impl AsRef<str>,
189 source: impl Into<String>,
190 ) -> Result<Self, FluentCatalogError> {
191 self.add_resource(locale, source)?;
192 Ok(self)
193 }
194
195 #[must_use]
198 pub fn render_reason(&self, reason: &DenialReason, locale: &Locale) -> String {
199 if reason.shape == DenyShape::Hidden {
200 return self.render_hidden(locale);
201 }
202
203 self.try_render_reason(reason, locale)
204 .unwrap_or_else(|| reason.code.as_str().to_owned())
205 }
206
207 #[must_use]
213 pub fn try_render_reason(&self, reason: &DenialReason, locale: &Locale) -> Option<String> {
214 if reason.shape == DenyShape::Hidden {
215 return self.try_render_hidden(locale);
216 }
217
218 for locale_key in self.locale_candidates(locale) {
219 if let Some(rendered) = self.render_from_bundle(reason, &locale_key) {
220 return Some(rendered);
221 }
222 }
223 None
224 }
225
226 fn render_hidden(&self, locale: &Locale) -> String {
227 self.try_render_hidden(locale)
228 .unwrap_or_else(|| self.hidden_fallback.clone())
229 }
230
231 fn try_render_hidden(&self, locale: &Locale) -> Option<String> {
232 for locale_key in self.locale_candidates(locale) {
233 if let Some(rendered) = self.render_message(&self.hidden_message_id, None, &locale_key)
234 {
235 return Some(rendered);
236 }
237 }
238 None
239 }
240
241 fn locale_candidates(&self, locale: &Locale) -> Vec<String> {
242 let mut candidates = Vec::new();
243 let fallback = self.fallback_locale.as_deref();
244 let ordered_candidates = [
245 Some(locale.as_str()),
246 locale
247 .as_str()
248 .split_once('-')
249 .map(|(language, _)| language),
250 fallback,
251 fallback.and_then(|value| value.split_once('-').map(|(language, _)| language)),
252 ];
253 for candidate in ordered_candidates.into_iter().flatten() {
254 push_locale_candidate(&mut candidates, candidate);
255 }
256
257 candidates
258 }
259
260 fn render_from_bundle(&self, reason: &DenialReason, locale_key: &str) -> Option<String> {
261 let args = fluent_args(reason);
262 self.render_message(reason.code.as_str(), Some(&args), locale_key)
263 }
264
265 fn render_message(
266 &self,
267 message_id: &str,
268 args: Option<&FluentArgs<'_>>,
269 locale_key: &str,
270 ) -> Option<String> {
271 let bundle = self.bundles.get(locale_key)?;
272 let message = bundle.get_message(message_id)?;
273 let pattern = message.value()?;
274 let mut errors = Vec::new();
275 let rendered = bundle.format_pattern(pattern, args, &mut errors);
276 errors.is_empty().then(|| rendered.into_owned())
277 }
278}
279
280impl ReasonCatalog for FluentCatalog {
281 fn render(&self, reason: &DenialReason, locale: &Locale) -> String {
282 self.render_reason(reason, locale)
283 }
284}
285
286#[derive(Debug, Error, PartialEq)]
288pub enum FluentCatalogError {
289 #[error("invalid fluent locale {locale}: {source}")]
291 InvalidLocale {
292 locale: String,
294 source: LanguageIdentifierError,
296 },
297 #[error("failed to parse fluent resource for {locale}: {}", errors.join("; "))]
299 Parse {
300 locale: String,
302 errors: Vec<String>,
304 },
305 #[error("failed to add fluent resource for {locale}: {}", errors.join("; "))]
307 Resource {
308 locale: String,
310 errors: Vec<String>,
312 },
313 #[error("{field} must not be empty")]
315 EmptyMessageId {
316 field: &'static str,
318 },
319 #[error("{field} must not be empty")]
321 EmptyFallbackMessage {
322 field: &'static str,
324 },
325}
326
327fn parse_locale(locale: &str) -> Result<LanguageIdentifier, FluentCatalogError> {
328 locale
329 .parse::<LanguageIdentifier>()
330 .map_err(|source| FluentCatalogError::InvalidLocale {
331 locale: locale.to_owned(),
332 source,
333 })
334}
335
336fn parse_resource(locale: &str, source: String) -> Result<FluentResource, FluentCatalogError> {
337 FluentResource::try_new(source).map_err(|(_resource, errors)| FluentCatalogError::Parse {
338 locale: locale.to_owned(),
339 errors: errors.into_iter().map(|error| error.to_string()).collect(),
340 })
341}
342
343fn display_errors(errors: Vec<FluentError>) -> Vec<String> {
344 errors.into_iter().map(|error| error.to_string()).collect()
345}
346
347fn validate_message_id(value: String) -> Result<String, FluentCatalogError> {
348 if value.trim().is_empty() {
349 Err(FluentCatalogError::EmptyMessageId {
350 field: "hidden message id",
351 })
352 } else {
353 Ok(value)
354 }
355}
356
357fn validate_hidden_fallback(value: String) -> Result<String, FluentCatalogError> {
358 if value.trim().is_empty() {
359 Err(FluentCatalogError::EmptyFallbackMessage {
360 field: "hidden fallback message",
361 })
362 } else {
363 Ok(value)
364 }
365}
366
367fn push_locale_candidate(candidates: &mut Vec<String>, locale: &str) {
368 if let Ok(locale) = locale.parse::<LanguageIdentifier>() {
369 let candidate = locale.to_string();
370 if !candidates.contains(&candidate) {
371 candidates.push(candidate);
372 }
373 }
374}
375
376fn fluent_args(reason: &DenialReason) -> FluentArgs<'static> {
377 let mut args = FluentArgs::with_capacity(reason.params.len());
378 for (key, value) in &reason.params {
379 args.set(key.as_str().to_owned(), fluent_value(value));
380 }
381 args
382}
383
384fn fluent_value(value: &ReasonValue) -> FluentValue<'static> {
385 match value {
386 ReasonValue::Str(value) => FluentValue::from(value.clone()),
387 ReasonValue::Int(value) => FluentValue::from(*value),
388 ReasonValue::Fact(fact) => FluentValue::from(fact.as_str().to_owned()),
389 ReasonValue::Outcome(value) => json_value(value),
390 }
391}
392
393fn json_value(value: &serde_json::Value) -> FluentValue<'static> {
394 match value {
395 serde_json::Value::Null => FluentValue::from("null"),
396 serde_json::Value::Bool(value) => FluentValue::from(value.to_string()),
397 serde_json::Value::Number(value) => number_value(value),
398 serde_json::Value::String(value) => FluentValue::from(value.clone()),
399 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
400 FluentValue::from(value.to_string())
401 }
402 }
403}
404
405fn number_value(value: &serde_json::Number) -> FluentValue<'static> {
406 value
407 .as_i64()
408 .map_or_else(|| unsigned_or_float_value(value), FluentValue::from)
409}
410
411fn unsigned_or_float_value(value: &serde_json::Number) -> FluentValue<'static> {
412 value.as_u64().map_or_else(
413 || float_value(value),
414 |value| {
415 i64::try_from(value)
416 .map_or_else(|_| FluentValue::from(value.to_string()), FluentValue::from)
417 },
418 )
419}
420
421fn float_value(value: &serde_json::Number) -> FluentValue<'static> {
422 value
423 .as_f64()
424 .map_or_else(|| FluentValue::from(value.to_string()), FluentValue::from)
425}