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 for locale_key in self.locale_candidates(locale) {
192 if let Some(rendered) = self.render_from_bundle(reason, &locale_key) {
193 return Some(rendered);
194 }
195 }
196 None
197 }
198
199 fn render_hidden(&self, locale: &Locale) -> String {
200 self.try_render_hidden(locale)
201 .unwrap_or_else(|| self.hidden_fallback.clone())
202 }
203
204 fn try_render_hidden(&self, locale: &Locale) -> Option<String> {
205 for locale_key in self.locale_candidates(locale) {
206 if let Some(rendered) = self.render_message(&self.hidden_message_id, None, &locale_key)
207 {
208 return Some(rendered);
209 }
210 }
211 None
212 }
213
214 fn locale_candidates(&self, locale: &Locale) -> Vec<String> {
215 let mut candidates = Vec::new();
216 push_locale_candidate(&mut candidates, locale.as_str());
217 if let Some((language, _rest)) = locale.as_str().split_once('-') {
218 push_locale_candidate(&mut candidates, language);
219 }
220 if let Some(fallback_locale) = &self.fallback_locale {
221 push_locale_candidate(&mut candidates, fallback_locale);
222 if let Some((language, _rest)) = fallback_locale.split_once('-') {
223 push_locale_candidate(&mut candidates, language);
224 }
225 }
226 candidates
227 }
228
229 fn render_from_bundle(&self, reason: &DenialReason, locale_key: &str) -> Option<String> {
230 let args = fluent_args(reason);
231 self.render_message(reason.code.as_str(), Some(&args), locale_key)
232 }
233
234 fn render_message(
235 &self,
236 message_id: &str,
237 args: Option<&FluentArgs<'_>>,
238 locale_key: &str,
239 ) -> Option<String> {
240 let bundle = self.bundles.get(locale_key)?;
241 let message = bundle.get_message(message_id)?;
242 let pattern = message.value()?;
243 let mut errors = Vec::new();
244 let rendered = bundle.format_pattern(pattern, args, &mut errors);
245 errors.is_empty().then(|| rendered.into_owned())
246 }
247}
248
249impl ReasonCatalog for FluentCatalog {
250 fn render(&self, reason: &DenialReason, locale: &Locale) -> String {
251 self.render_reason(reason, locale)
252 }
253}
254
255#[derive(Debug, Error, PartialEq)]
257pub enum FluentCatalogError {
258 #[error("invalid fluent locale {locale}: {source}")]
260 InvalidLocale {
261 locale: String,
263 source: LanguageIdentifierError,
265 },
266 #[error("failed to parse fluent resource for {locale}: {}", errors.join("; "))]
268 Parse {
269 locale: String,
271 errors: Vec<String>,
273 },
274 #[error("failed to add fluent resource for {locale}: {}", errors.join("; "))]
276 Resource {
277 locale: String,
279 errors: Vec<String>,
281 },
282 #[error("{field} must not be empty")]
284 EmptyMessageId {
285 field: &'static str,
287 },
288 #[error("{field} must not be empty")]
290 EmptyFallbackMessage {
291 field: &'static str,
293 },
294}
295
296fn parse_locale(locale: &str) -> Result<LanguageIdentifier, FluentCatalogError> {
297 locale
298 .parse::<LanguageIdentifier>()
299 .map_err(|source| FluentCatalogError::InvalidLocale {
300 locale: locale.to_owned(),
301 source,
302 })
303}
304
305fn parse_resource(locale: &str, source: String) -> Result<FluentResource, FluentCatalogError> {
306 FluentResource::try_new(source).map_err(|(_resource, errors)| FluentCatalogError::Parse {
307 locale: locale.to_owned(),
308 errors: errors.into_iter().map(|error| error.to_string()).collect(),
309 })
310}
311
312fn display_errors(errors: Vec<FluentError>) -> Vec<String> {
313 errors.into_iter().map(|error| error.to_string()).collect()
314}
315
316fn validate_message_id(value: String) -> Result<String, FluentCatalogError> {
317 if value.trim().is_empty() {
318 Err(FluentCatalogError::EmptyMessageId {
319 field: "hidden message id",
320 })
321 } else {
322 Ok(value)
323 }
324}
325
326fn validate_hidden_fallback(value: String) -> Result<String, FluentCatalogError> {
327 if value.trim().is_empty() {
328 Err(FluentCatalogError::EmptyFallbackMessage {
329 field: "hidden fallback message",
330 })
331 } else {
332 Ok(value)
333 }
334}
335
336fn push_locale_candidate(candidates: &mut Vec<String>, locale: &str) {
337 if let Ok(locale) = locale.parse::<LanguageIdentifier>() {
338 let candidate = locale.to_string();
339 if !candidates.contains(&candidate) {
340 candidates.push(candidate);
341 }
342 }
343}
344
345fn fluent_args(reason: &DenialReason) -> FluentArgs<'static> {
346 let mut args = FluentArgs::with_capacity(reason.params.len());
347 for (key, value) in &reason.params {
348 args.set(key.as_str().to_owned(), fluent_value(value));
349 }
350 args
351}
352
353fn fluent_value(value: &ReasonValue) -> FluentValue<'static> {
354 match value {
355 ReasonValue::Str(value) => FluentValue::from(value.clone()),
356 ReasonValue::Int(value) => FluentValue::from(*value),
357 ReasonValue::Fact(fact) => FluentValue::from(fact.as_str().to_owned()),
358 ReasonValue::Outcome(value) => json_value(value),
359 }
360}
361
362fn json_value(value: &serde_json::Value) -> FluentValue<'static> {
363 match value {
364 serde_json::Value::Null => FluentValue::from("null"),
365 serde_json::Value::Bool(value) => FluentValue::from(value.to_string()),
366 serde_json::Value::Number(value) => number_value(value),
367 serde_json::Value::String(value) => FluentValue::from(value.clone()),
368 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
369 FluentValue::from(value.to_string())
370 }
371 }
372}
373
374fn number_value(value: &serde_json::Number) -> FluentValue<'static> {
375 value
376 .as_i64()
377 .map_or_else(|| unsigned_or_float_value(value), FluentValue::from)
378}
379
380fn unsigned_or_float_value(value: &serde_json::Number) -> FluentValue<'static> {
381 value.as_u64().map_or_else(
382 || float_value(value),
383 |value| {
384 i64::try_from(value)
385 .map_or_else(|_| FluentValue::from(value.to_string()), FluentValue::from)
386 },
387 )
388}
389
390fn float_value(value: &serde_json::Number) -> FluentValue<'static> {
391 value
392 .as_f64()
393 .map_or_else(|| FluentValue::from(value.to_string()), FluentValue::from)
394}