Skip to main content

ferroui_protranslate_core/
provider.rs

1use crate::{Culture, TranslationError};
2use std::{
3    cell::{Cell, RefCell},
4    collections::HashMap,
5    rc::Rc,
6};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ProTranslateDiagnosticKind {
10    MissingTranslation,
11    ProviderFailure,
12    FormatFailure,
13    RegionProfileFailure,
14}
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ProTranslateDiagnosticSeverity {
17    Information,
18    Warning,
19    Error,
20}
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ProTranslateDiagnostic {
23    pub kind: ProTranslateDiagnosticKind,
24    pub severity: ProTranslateDiagnosticSeverity,
25    pub message: String,
26    pub key: Option<String>,
27    pub culture: Option<Culture>,
28    pub provider_name: Option<String>,
29    pub error: Option<TranslationError>,
30}
31impl ProTranslateDiagnostic {
32    pub(crate) fn provider(
33        key: &str,
34        culture: &Culture,
35        provider: &str,
36        error: TranslationError,
37    ) -> Self {
38        Self {
39            kind: ProTranslateDiagnosticKind::ProviderFailure,
40            severity: ProTranslateDiagnosticSeverity::Error,
41            message: format!("Provider '{provider}' failed for '{key}' in '{culture}': {error}"),
42            key: Some(key.into()),
43            culture: Some(culture.clone()),
44            provider_name: Some(provider.into()),
45            error: Some(error),
46        }
47    }
48}
49pub trait ProTranslateDiagnosticSink {
50    fn report(&self, diagnostic: &ProTranslateDiagnostic);
51}
52impl<F: Fn(&ProTranslateDiagnostic)> ProTranslateDiagnosticSink for F {
53    fn report(&self, diagnostic: &ProTranslateDiagnostic) {
54        self(diagnostic);
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct LocalizedString {
60    pub key: String,
61    pub value: String,
62    pub culture: Culture,
63    pub resource_not_found: bool,
64    pub provider_name: String,
65    pub diagnostics: Vec<ProTranslateDiagnostic>,
66}
67impl LocalizedString {
68    pub fn found(
69        key: impl Into<String>,
70        value: impl Into<String>,
71        culture: Culture,
72        provider_name: impl Into<String>,
73    ) -> Self {
74        Self {
75            key: key.into(),
76            value: value.into(),
77            culture,
78            resource_not_found: false,
79            provider_name: provider_name.into(),
80            diagnostics: Vec::new(),
81        }
82    }
83    pub fn missing(
84        key: impl Into<String>,
85        culture: Culture,
86        provider_name: impl Into<String>,
87    ) -> Self {
88        let key = key.into();
89        Self {
90            value: key.clone(),
91            key,
92            culture,
93            resource_not_found: true,
94            provider_name: provider_name.into(),
95            diagnostics: Vec::new(),
96        }
97    }
98}
99impl std::fmt::Display for LocalizedString {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.write_str(&self.value)
102    }
103}
104/// A provider performs one exact-culture lookup; the service owns fallback.
105/// Empty values are successful translations. Failures are distinct from misses.
106pub trait TranslationProvider {
107    fn name(&self) -> &str;
108    fn get_string(&self, key: &str, culture: &Culture)
109        -> Result<LocalizedString, TranslationError>;
110    /// Change this token when mutable catalog contents change. The service
111    /// invalidates cached hits and misses before its next lookup.
112    fn revision(&self) -> u64 {
113        0
114    }
115}
116pub struct InMemoryTranslationProvider {
117    name: String,
118    entries: RefCell<HashMap<(Culture, String), String>>,
119    revision: Cell<u64>,
120}
121impl Default for InMemoryTranslationProvider {
122    fn default() -> Self {
123        Self {
124            name: "InMemory".into(),
125            entries: RefCell::default(),
126            revision: Cell::new(0),
127        }
128    }
129}
130/// Validate a resource key at a provider boundary, including direct lookups that
131/// do not pass through a translation service.
132pub fn validate_translation_key(key: &str) -> Result<(), TranslationError> {
133    if key.trim().is_empty() {
134        Err(TranslationError::InvalidKey)
135    } else {
136        Ok(())
137    }
138}
139fn validate_provider_name(name: &str) -> Result<(), TranslationError> {
140    if name.trim().is_empty() {
141        Err(TranslationError::InvalidProviderName)
142    } else {
143        Ok(())
144    }
145}
146impl InMemoryTranslationProvider {
147    /// Construct a named catalog. Use `try_new` for names supplied by users.
148    ///
149    /// # Panics
150    /// Panics if the name is empty or whitespace.
151    pub fn new(name: impl Into<String>) -> Self {
152        Self::try_new(name).expect("a translation provider must have a nonempty name")
153    }
154    pub fn try_new(name: impl Into<String>) -> Result<Self, TranslationError> {
155        let name = name.into();
156        validate_provider_name(&name)?;
157        Ok(Self {
158            name,
159            ..Self::default()
160        })
161    }
162    /// Insert a translation. Empty values are intentional translations.
163    ///
164    /// # Panics
165    /// Panics if the key is empty or whitespace; `try_insert` is recoverable.
166    pub fn insert(
167        &self,
168        culture: Culture,
169        key: impl Into<String>,
170        value: impl Into<String>,
171    ) -> Option<String> {
172        self.try_insert(culture, key, value)
173            .expect("a translation key must not be empty or whitespace")
174    }
175    pub fn try_insert(
176        &self,
177        culture: Culture,
178        key: impl Into<String>,
179        value: impl Into<String>,
180    ) -> Result<Option<String>, TranslationError> {
181        let key = key.into();
182        validate_translation_key(&key)?;
183        let result = self
184            .entries
185            .borrow_mut()
186            .insert((culture, key), value.into());
187        self.revision.set(self.revision.get().wrapping_add(1));
188        Ok(result)
189    }
190    /// Add a culture's catalog atomically: an invalid key rejects the complete
191    /// batch without changing translations or the provider revision.
192    pub fn insert_many<K: Into<String>, V: Into<String>>(
193        &self,
194        culture: Culture,
195        translations: impl IntoIterator<Item = (K, V)>,
196    ) -> Result<(), TranslationError> {
197        let translations = translations
198            .into_iter()
199            .map(|(key, value)| {
200                let key = key.into();
201                validate_translation_key(&key)?;
202                Ok((key, value.into()))
203            })
204            .collect::<Result<Vec<_>, TranslationError>>()?;
205        if !translations.is_empty() {
206            self.entries.borrow_mut().extend(
207                translations
208                    .into_iter()
209                    .map(|(key, value)| ((culture.clone(), key), value)),
210            );
211            self.revision.set(self.revision.get().wrapping_add(1));
212        }
213        Ok(())
214    }
215    pub fn remove(&self, culture: &Culture, key: &str) -> Option<String> {
216        let result = self
217            .entries
218            .borrow_mut()
219            .remove(&(culture.clone(), key.into()));
220        if result.is_some() {
221            self.revision.set(self.revision.get().wrapping_add(1));
222        }
223        result
224    }
225    pub fn len(&self) -> usize {
226        self.entries.borrow().len()
227    }
228    pub fn is_empty(&self) -> bool {
229        self.entries.borrow().is_empty()
230    }
231    pub fn clear(&self) {
232        self.entries.borrow_mut().clear();
233        self.revision.set(self.revision.get().wrapping_add(1));
234    }
235}
236impl TranslationProvider for InMemoryTranslationProvider {
237    fn name(&self) -> &str {
238        &self.name
239    }
240    fn get_string(
241        &self,
242        key: &str,
243        culture: &Culture,
244    ) -> Result<LocalizedString, TranslationError> {
245        validate_translation_key(key)?;
246        Ok(
247            match self.entries.borrow().get(&(culture.clone(), key.into())) {
248                Some(value) => LocalizedString::found(key, value, culture.clone(), &self.name),
249                None => LocalizedString::missing(key, culture.clone(), &self.name),
250            },
251        )
252    }
253    fn revision(&self) -> u64 {
254        self.revision.get()
255    }
256}
257#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
258pub enum TranslationProviderFailureBehavior {
259    #[default]
260    ReportAndContinue,
261    ReportAndReturnError,
262}
263#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
264pub enum TranslationFormatFailureBehavior {
265    #[default]
266    ReportAndReturnUnformatted,
267    ReportAndReturnError,
268}
269/// Ordered, composable providers. Provider failures remain in result diagnostics
270/// even if a later provider successfully resolves the value.
271pub struct CompositeTranslationProvider {
272    providers: Vec<Rc<dyn TranslationProvider>>,
273    failure_behavior: TranslationProviderFailureBehavior,
274    child_revisions: RefCell<Vec<u64>>,
275    revision: Cell<u64>,
276}
277impl CompositeTranslationProvider {
278    pub fn new(providers: Vec<Rc<dyn TranslationProvider>>) -> Self {
279        Self {
280            providers,
281            failure_behavior: Default::default(),
282            child_revisions: RefCell::default(),
283            revision: Cell::new(0),
284        }
285    }
286    pub fn with_failure_behavior(mut self, behavior: TranslationProviderFailureBehavior) -> Self {
287        self.failure_behavior = behavior;
288        self
289    }
290    pub fn providers(&self) -> &[Rc<dyn TranslationProvider>] {
291        &self.providers
292    }
293}
294impl TranslationProvider for CompositeTranslationProvider {
295    fn name(&self) -> &str {
296        "Composite"
297    }
298    fn get_string(
299        &self,
300        key: &str,
301        culture: &Culture,
302    ) -> Result<LocalizedString, TranslationError> {
303        validate_translation_key(key)?;
304        let mut diagnostics = Vec::new();
305        for provider in &self.providers {
306            match provider.get_string(key, culture) {
307                Ok(mut localized) => {
308                    diagnostics.append(&mut localized.diagnostics);
309                    if !localized.resource_not_found {
310                        localized.diagnostics = diagnostics;
311                        return Ok(localized);
312                    }
313                }
314                Err(error) => {
315                    if self.failure_behavior
316                        == TranslationProviderFailureBehavior::ReportAndReturnError
317                    {
318                        return Err(error);
319                    }
320                    diagnostics.push(ProTranslateDiagnostic::provider(
321                        key,
322                        culture,
323                        provider.name(),
324                        error,
325                    ));
326                }
327            }
328        }
329        let mut missing = LocalizedString::missing(key, culture.clone(), self.name());
330        missing.diagnostics = diagnostics;
331        Ok(missing)
332    }
333    fn revision(&self) -> u64 {
334        // Comparing the actual revision vector avoids collisions from hashing
335        // unrelated mutable providers into a single cache token.
336        let revisions: Vec<_> = self.providers.iter().map(|p| p.revision()).collect();
337        let mut previous = self.child_revisions.borrow_mut();
338        if *previous != revisions {
339            *previous = revisions;
340            self.revision.set(self.revision.get().wrapping_add(1));
341        }
342        self.revision.get()
343    }
344}
345
346/// Host bridge for resource managers, localizer services, embedded stores, and
347/// other application providers. `Ok(None)` is a miss; `Ok(Some(""))` is a hit.
348/// Hosts remain responsible for calling `TranslationService::refresh` after
349/// changing external data when already-bound values must refresh immediately.
350pub struct CallbackTranslationProvider<F> {
351    name: String,
352    callback: F,
353    revision: Cell<u64>,
354}
355impl<F> CallbackTranslationProvider<F>
356where
357    F: Fn(&str, &Culture) -> Result<Option<String>, TranslationError>,
358{
359    pub fn new(name: impl Into<String>, callback: F) -> Self {
360        Self::try_new(name, callback).expect("a translation provider must have a nonempty name")
361    }
362    pub fn try_new(name: impl Into<String>, callback: F) -> Result<Self, TranslationError> {
363        let name = name.into();
364        validate_provider_name(&name)?;
365        Ok(Self {
366            name,
367            callback,
368            revision: Cell::new(0),
369        })
370    }
371    pub fn invalidate(&self) {
372        self.revision.set(self.revision.get().wrapping_add(1));
373    }
374}
375impl<F> TranslationProvider for CallbackTranslationProvider<F>
376where
377    F: Fn(&str, &Culture) -> Result<Option<String>, TranslationError>,
378{
379    fn name(&self) -> &str {
380        &self.name
381    }
382    fn get_string(
383        &self,
384        key: &str,
385        culture: &Culture,
386    ) -> Result<LocalizedString, TranslationError> {
387        validate_translation_key(key)?;
388        Ok(match (self.callback)(key, culture)? {
389            Some(value) => LocalizedString::found(key, value, culture.clone(), &self.name),
390            None => LocalizedString::missing(key, culture.clone(), &self.name),
391        })
392    }
393    fn revision(&self) -> u64 {
394        self.revision.get()
395    }
396}