i18n_embed/
assets.rs

1use std::borrow::Cow;
2
3use rust_embed::RustEmbed;
4
5use crate::I18nEmbedError;
6
7/// A trait to handle the retrieval of localization assets.
8pub trait I18nAssets {
9    /// Get localization asset files that correspond to the specified `file_path`. Returns an empty
10    /// [`Vec`] if the asset does not exist, or unable to obtain the asset due to a non-critical
11    /// error.
12    fn get_files(&self, file_path: &str) -> Vec<Cow<'_, [u8]>>;
13    /// Get an iterator over the file paths of the localization assets. There may be duplicates
14    /// where multiple files exist for the same file path.
15    fn filenames_iter(&self) -> Box<dyn Iterator<Item = String> + '_>;
16    /// A method to allow users of this trait to subscribe to change events, and reload assets when
17    /// they have changed. The subscription will be cancelled when the returned [`Watcher`] is
18    /// dropped.
19    ///
20    /// **NOTE**: The implementation of this method is optional, don't rely on it functioning for all
21    /// implementations.
22    fn subscribe_changed(
23        &self,
24        #[allow(unused_variables)] changed: std::sync::Arc<dyn Fn() + Send + Sync + 'static>,
25    ) -> Result<Box<dyn Watcher + Send + Sync + 'static>, I18nEmbedError> {
26        Ok(Box::new(()))
27    }
28}
29
30impl Watcher for () {}
31
32impl<T> I18nAssets for T
33where
34    T: RustEmbed,
35{
36    fn get_files(&self, file_path: &str) -> Vec<Cow<'_, [u8]>> {
37        Self::get(file_path)
38            .map(|file| file.data)
39            .into_iter()
40            .collect()
41    }
42
43    fn filenames_iter(&self) -> Box<dyn Iterator<Item = String>> {
44        Box::new(Self::iter().map(|filename| filename.to_string()))
45    }
46
47    #[allow(unused_variables)]
48    fn subscribe_changed(
49        &self,
50        changed: std::sync::Arc<dyn Fn() + Send + Sync + 'static>,
51    ) -> Result<Box<dyn Watcher + Send + Sync + 'static>, I18nEmbedError> {
52        Ok(Box::new(()))
53    }
54}
55
56/// A wrapper for [`rust_embed::RustEmbed`] that supports notifications when files have changed on
57/// the file system. A wrapper is required to provide `base_dir` as this is unavailable in the type
58/// derived by the [`rust_embed::RustEmbed`] macro.
59///
60/// ⚠️ *This type requires the following crate features to be activated: `autoreload`.*
61#[cfg(feature = "autoreload")]
62#[derive(Debug)]
63pub struct RustEmbedNotifyAssets<T: rust_embed::RustEmbed> {
64    base_dir: std::path::PathBuf,
65    embed: core::marker::PhantomData<T>,
66}
67
68#[cfg(feature = "autoreload")]
69impl<T: rust_embed::RustEmbed> RustEmbedNotifyAssets<T> {
70    /// Construct a new [`RustEmbedNotifyAssets`].
71    pub fn new(base_dir: impl Into<std::path::PathBuf>) -> Self {
72        Self {
73            base_dir: base_dir.into(),
74            embed: core::marker::PhantomData,
75        }
76    }
77}
78
79#[cfg(feature = "autoreload")]
80impl<T> I18nAssets for RustEmbedNotifyAssets<T>
81where
82    T: RustEmbed,
83{
84    fn get_files(&self, file_path: &str) -> Vec<Cow<'_, [u8]>> {
85        T::get(file_path)
86            .map(|file| file.data)
87            .into_iter()
88            .collect()
89    }
90
91    fn filenames_iter(&self) -> Box<dyn Iterator<Item = String>> {
92        Box::new(T::iter().map(|filename| filename.to_string()))
93    }
94
95    fn subscribe_changed(
96        &self,
97        changed: std::sync::Arc<dyn Fn() + Send + Sync + 'static>,
98    ) -> Result<Box<dyn Watcher + Send + Sync + 'static>, I18nEmbedError> {
99        let base_dir = &self.base_dir;
100        if base_dir.is_dir() {
101            log::debug!("Watching for changed files in {:?}", self.base_dir);
102            notify_watcher(base_dir, changed).map_err(Into::into)
103        } else {
104            log::debug!("base_dir {base_dir:?} does not yet exist, unable to watch for changes");
105            Ok(Box::new(()))
106        }
107    }
108}
109
110/// An [I18nAssets] implementation which pulls assets from the OS
111/// file system.
112#[cfg(feature = "filesystem-assets")]
113#[derive(Debug)]
114pub struct FileSystemAssets {
115    base_dir: std::path::PathBuf,
116    #[cfg(feature = "autoreload")]
117    notify_changes_enabled: bool,
118}
119
120#[cfg(feature = "filesystem-assets")]
121impl FileSystemAssets {
122    /// Create a new `FileSystemAssets` instance, all files will be
123    /// read from within the specified base directory.
124    pub fn try_new<P: Into<std::path::PathBuf>>(base_dir: P) -> Result<Self, I18nEmbedError> {
125        let base_dir = base_dir.into();
126
127        if !base_dir.exists() {
128            return Err(I18nEmbedError::DirectoryDoesNotExist(base_dir));
129        }
130
131        if !base_dir.is_dir() {
132            return Err(I18nEmbedError::PathIsNotDirectory(base_dir));
133        }
134
135        Ok(Self {
136            base_dir,
137            #[cfg(feature = "autoreload")]
138            notify_changes_enabled: false,
139        })
140    }
141
142    /// Enable the notification of changes in the [`I18nAssets`] implementation.
143    #[cfg(feature = "autoreload")]
144    pub fn notify_changes_enabled(mut self, enabled: bool) -> Self {
145        self.notify_changes_enabled = enabled;
146        self
147    }
148}
149
150/// An error that occurs during notification of changes when the `autoreload feature is enabled.`
151///
152/// ⚠️ *This type requires the following crate features to be activated: `filesystem-assets`.*
153#[cfg(feature = "autoreload")]
154#[derive(Debug)]
155pub struct NotifyError(notify::Error);
156
157#[cfg(feature = "autoreload")]
158impl From<notify::Error> for NotifyError {
159    fn from(value: notify::Error) -> Self {
160        Self(value)
161    }
162}
163
164#[cfg(feature = "autoreload")]
165impl From<notify::Error> for I18nEmbedError {
166    fn from(value: notify::Error) -> Self {
167        Self::Notify(value.into())
168    }
169}
170
171#[cfg(feature = "autoreload")]
172impl std::fmt::Display for NotifyError {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        self.0.fmt(f)
175    }
176}
177
178#[cfg(feature = "autoreload")]
179impl std::error::Error for NotifyError {}
180
181#[cfg(feature = "autoreload")]
182fn notify_watcher(
183    base_dir: &std::path::Path,
184    changed: std::sync::Arc<dyn Fn() + Send + Sync + 'static>,
185) -> notify::Result<Box<dyn Watcher + Send + Sync + 'static>> {
186    let mut watcher = notify::recommended_watcher(move |event_result| {
187        let event: notify::Event = match event_result {
188            Ok(event) => event,
189            Err(error) => {
190                log::error!("{error}");
191                return;
192            }
193        };
194        match event.kind {
195            notify::EventKind::Any
196            | notify::EventKind::Create(_)
197            | notify::EventKind::Modify(_)
198            | notify::EventKind::Remove(_)
199            | notify::EventKind::Other => changed(),
200            _ => {}
201        }
202    })?;
203
204    notify::Watcher::watch(&mut watcher, base_dir, notify::RecursiveMode::Recursive)?;
205
206    Ok(Box::new(watcher))
207}
208
209/// An entity that watches for changes to localization resources.
210///
211/// NOTE: Currently we rely in the implicit [`Drop`] implementation to remove file system watches,
212/// in the future there may be new methods added to this trait.
213pub trait Watcher {}
214
215#[cfg(feature = "autoreload")]
216impl Watcher for notify::RecommendedWatcher {}
217
218#[cfg(feature = "filesystem-assets")]
219impl I18nAssets for FileSystemAssets {
220    fn get_files(&self, file_path: &str) -> Vec<Cow<'_, [u8]>> {
221        let full_path = self.base_dir.join(file_path);
222
223        if !(full_path.is_file() && full_path.exists()) {
224            return Vec::new();
225        }
226
227        match std::fs::read(full_path) {
228            Ok(contents) => vec![Cow::from(contents)],
229            Err(e) => {
230                log::error!(
231                    target: "i18n_embed::assets", 
232                    "Unexpected error while reading localization asset file: {}", 
233                    e);
234                Vec::new()
235            }
236        }
237    }
238
239    fn filenames_iter(&self) -> Box<dyn Iterator<Item = String>> {
240        Box::new(
241            walkdir::WalkDir::new(&self.base_dir)
242                .into_iter()
243                .filter_map(|f| match f {
244                    Ok(f) => {
245                        if f.file_type().is_file() {
246                            match f.file_name().to_str() {
247                                Some(filename) => Some(filename.to_string()),
248                                None => {
249                                    log::error!(
250                                target: "i18n_embed::assets", 
251                                "Filename {:?} is not valid UTF-8.", 
252                                f.file_name());
253                                    None
254                                }
255                            }
256                        } else {
257                            None
258                        }
259                    }
260                    Err(err) => {
261                        log::error!(
262                    target: "i18n_embed::assets", 
263                    "Unexpected error while gathering localization asset filenames: {}", 
264                    err);
265                        None
266                    }
267                }),
268        )
269    }
270
271    /// See [`FileSystemAssets::notify_changes_enabled`] to enable this implementation.
272    /// ⚠️ *This method requires the following crate features to be activated: `autoreload`.*
273    #[cfg(feature = "autoreload")]
274    fn subscribe_changed(
275        &self,
276        changed: std::sync::Arc<dyn Fn() + Send + Sync + 'static>,
277    ) -> Result<Box<dyn Watcher + Send + Sync + 'static>, I18nEmbedError> {
278        if self.notify_changes_enabled {
279            notify_watcher(&self.base_dir, changed).map_err(Into::into)
280        } else {
281            Ok(Box::new(()))
282        }
283    }
284}
285
286/// A way to multiplex implementations of [`I18nAssets`].
287pub struct AssetsMultiplexor {
288    /// Assets that are multiplexed, ordered from most to least priority.
289    assets: Vec<Box<dyn I18nAssets + Send + Sync + 'static>>,
290}
291
292impl std::fmt::Debug for AssetsMultiplexor {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        f.debug_struct("AssetsMultiplexor")
295            .field(
296                "assets",
297                &self.assets.iter().map(|_| "<ASSET>").collect::<Vec<_>>(),
298            )
299            .finish()
300    }
301}
302
303impl AssetsMultiplexor {
304    /// Construct a new [`AssetsMultiplexor`]. `assets` are specified in order of priority of
305    /// processing for the [`crate::LanguageLoader`].
306    pub fn new(
307        assets: impl IntoIterator<Item = Box<dyn I18nAssets + Send + Sync + 'static>>,
308    ) -> Self {
309        Self {
310            assets: assets.into_iter().collect(),
311        }
312    }
313}
314
315#[allow(dead_code)] // We rely on the Drop implementation of the Watcher to remove the file system watch.
316struct Watchers(Vec<Box<dyn Watcher + Send + Sync + 'static>>);
317
318impl Watcher for Watchers {}
319
320impl I18nAssets for AssetsMultiplexor {
321    fn get_files(&self, file_path: &str) -> Vec<Cow<'_, [u8]>> {
322        self.assets
323            .iter()
324            .flat_map(|assets| assets.get_files(file_path))
325            .collect()
326    }
327
328    fn filenames_iter(&self) -> Box<dyn Iterator<Item = String> + '_> {
329        Box::new(
330            self.assets
331                .iter()
332                .flat_map(|assets| assets.filenames_iter()),
333        )
334    }
335
336    fn subscribe_changed(
337        &self,
338        changed: std::sync::Arc<dyn Fn() + Send + Sync + 'static>,
339    ) -> Result<Box<dyn Watcher + Send + Sync + 'static>, I18nEmbedError> {
340        let watchers: Vec<_> = self
341            .assets
342            .iter()
343            .map(|assets| assets.subscribe_changed(changed.clone()))
344            .collect::<Result<_, _>>()?;
345        Ok(Box::new(Watchers(watchers)))
346    }
347}