i18n_config/
lib.rs

1//! This library contains the configuration structs (along with their
2//! parsing functions) for the
3//! [cargo-i18n](https://crates.io/crates/cargo_i18n) tool/system.
4
5mod fluent;
6mod gettext;
7
8pub use fluent::FluentConfig;
9pub use gettext::GettextConfig;
10
11use std::fs::read_to_string;
12use std::io;
13use std::{
14    fmt::Display,
15    path::{Path, PathBuf},
16};
17
18use log::{debug, error};
19use serde_derive::Deserialize;
20use thiserror::Error;
21use unic_langid::LanguageIdentifier;
22
23/// An error type explaining why a crate failed to verify.
24#[derive(Debug, Error)]
25pub enum WhyNotCrate {
26    #[error("there is no Cargo.toml present")]
27    NoCargoToml,
28    #[error("it is a workspace")]
29    Workspace,
30}
31
32/// An error type for use with the `i18n-config` crate.
33#[derive(Debug, Error)]
34pub enum I18nConfigError {
35    #[error("The specified path is not a crate because {1}.")]
36    NotACrate(PathBuf, WhyNotCrate),
37    #[error("Cannot read file {0:?} in the current working directory {1:?} because {2}.")]
38    CannotReadFile(PathBuf, io::Result<PathBuf>, #[source] io::Error),
39    #[error("Cannot parse Cargo configuration file {0:?} because {1}.")]
40    CannotParseCargoToml(PathBuf, String),
41    #[error("Cannot deserialize toml file {0:?} because {1}.")]
42    CannotDeserializeToml(PathBuf, basic_toml::Error),
43    #[error("Cannot parse i18n configuration file {0:?} because {1}.")]
44    CannotPaseI18nToml(PathBuf, String),
45    #[error("There is no i18n configuration file present for the crate {0}.")]
46    NoI18nConfig(String),
47    #[error("The \"{0}\" is required to be present in the i18n configuration file \"{1}\"")]
48    OptionMissingInI18nConfig(String, PathBuf),
49    #[error("There is no parent crate for {0}. Required because {1}.")]
50    NoParentCrate(String, String),
51    #[error(
52        "There is no i18n config file present for the parent crate of {0}. Required because {1}."
53    )]
54    NoParentI18nConfig(String, String),
55    #[error("Cannot read `CARGO_MANIFEST_DIR` environment variable.")]
56    CannotReadCargoManifestDir,
57}
58
59#[derive(Deserialize)]
60struct RawCrate {
61    #[serde(alias = "workspace")]
62    package: RawPackage,
63}
64
65#[derive(Deserialize)]
66struct RawPackage {
67    name: String,
68    version: String,
69}
70
71/// Represents a rust crate.
72#[derive(Debug, Clone)]
73pub struct Crate<'a> {
74    /// The name of the crate.
75    pub name: String,
76    /// The version of the crate.
77    pub version: String,
78    /// The path to the crate.
79    pub path: PathBuf,
80    /// Path to the parent crate which is triggering the localization
81    /// for this crate.
82    pub parent: Option<&'a Crate<'a>>,
83    /// The file path expected to be used for `i18n_config` relative to this crate's root.
84    pub config_file_path: PathBuf,
85    /// The localization config for this crate (if it exists).
86    pub i18n_config: Option<I18nConfig>,
87}
88
89impl<'a> Crate<'a> {
90    /// Read crate from `Cargo.toml` i18n config using the
91    /// `config_file_path` (if there is one).
92    pub fn from<P1: Into<PathBuf>, P2: Into<PathBuf>>(
93        path: P1,
94        parent: Option<&'a Crate>,
95        config_file_path: P2,
96    ) -> Result<Crate<'a>, I18nConfigError> {
97        let path_into = path.into();
98
99        let config_file_path_into = config_file_path.into();
100
101        let cargo_path = path_into.join("Cargo.toml");
102
103        if !cargo_path.exists() {
104            return Err(I18nConfigError::NotACrate(
105                path_into,
106                WhyNotCrate::NoCargoToml,
107            ));
108        }
109
110        let toml_str = read_to_string(cargo_path.clone()).map_err(|err| {
111            I18nConfigError::CannotReadFile(cargo_path.clone(), std::env::current_dir(), err)
112        })?;
113
114        let cargo_toml: RawCrate = basic_toml::from_str(&toml_str)
115            .map_err(|err| I18nConfigError::CannotDeserializeToml(cargo_path.clone(), err))?;
116
117        let full_config_file_path = path_into.join(&config_file_path_into);
118        let i18n_config = if full_config_file_path.exists() {
119            Some(I18nConfig::from_file(&full_config_file_path)?)
120        } else {
121            None
122        };
123
124        Ok(Crate {
125            name: cargo_toml.package.name,
126            version: cargo_toml.package.version,
127            path: path_into,
128            parent,
129            config_file_path: config_file_path_into,
130            i18n_config,
131        })
132    }
133
134    /// The name of the module/library used for this crate. Replaces
135    /// `-` characters with `_` in the crate name.
136    pub fn module_name(&self) -> String {
137        self.name.replace('-', "_")
138    }
139
140    /// If there is a parent, get it's
141    /// [I18nConfig#active_config()](I18nConfig#active_config()),
142    /// otherwise return None.
143    pub fn parent_active_config(
144        &self,
145    ) -> Result<Option<(&'_ Crate, &'_ I18nConfig)>, I18nConfigError> {
146        match self.parent {
147            Some(parent) => parent.active_config(),
148            None => Ok(None),
149        }
150    }
151
152    /// Identify the config which should be used for this crate, and
153    /// the crate (either this crate or one of it's parents)
154    /// associated with that config.
155    pub fn active_config(&self) -> Result<Option<(&'_ Crate, &'_ I18nConfig)>, I18nConfigError> {
156        debug!("Resolving active config for {0}", self);
157        match &self.i18n_config {
158            Some(config) => {
159                if let Some(gettext_config) = &config.gettext {
160                    if gettext_config.extract_to_parent {
161                        debug!("Resolving active config for {0}, extract_to_parent is true, so attempting to obtain parent config.", self);
162
163                        if self.parent.is_none() {
164                            return Err(I18nConfigError::NoParentCrate(
165                                self.to_string(),
166                                "the gettext extract_to_parent option is active".to_string(),
167                            ));
168                        }
169
170                        return Ok(Some(self.parent_active_config()?.ok_or_else(|| {
171                            I18nConfigError::NoParentI18nConfig(
172                                self.to_string(),
173                                "the gettext extract_to_parent option is active".to_string(),
174                            )
175                        })?));
176                    }
177                }
178
179                Ok(Some((self, config)))
180            }
181            None => {
182                debug!(
183                    "{0} has no i18n config, attempting to obtain parent config instead.",
184                    self
185                );
186                self.parent_active_config()
187            }
188        }
189    }
190
191    /// Get the [I18nConfig](I18nConfig) in this crate, or return an
192    /// error if there is none present.
193    pub fn config_or_err(&self) -> Result<&I18nConfig, I18nConfigError> {
194        match &self.i18n_config {
195            Some(config) => Ok(config),
196            None => Err(I18nConfigError::NoI18nConfig(self.to_string())),
197        }
198    }
199
200    /// Get the [GettextConfig](GettextConfig) in this crate, or
201    /// return an error if there is none present.
202    pub fn gettext_config_or_err(&self) -> Result<&GettextConfig, I18nConfigError> {
203        match &self.config_or_err()?.gettext {
204            Some(gettext_config) => Ok(gettext_config),
205            None => Err(I18nConfigError::OptionMissingInI18nConfig(
206                "gettext section".to_string(),
207                self.config_file_path.clone(),
208            )),
209        }
210    }
211
212    /// If this crate has a parent, check whether the parent wants to
213    /// collate subcrates string extraction, as per the parent's
214    /// [GettextConfig#collate_extracted_subcrates](GettextConfig#collate_extracted_subcrates).
215    /// This also requires that the current crate's [GettextConfig#extract_to_parent](GettextConfig#extract_to_parent)
216    /// is **true**.
217    ///
218    /// Returns **false** if there is no parent or the parent has no gettext config.
219    pub fn collated_subcrate(&self) -> bool {
220        let parent_extract_to_subcrate = self
221            .parent
222            .map(|parent_crate| {
223                parent_crate
224                    .gettext_config_or_err()
225                    .map(|parent_gettext_config| parent_gettext_config.collate_extracted_subcrates)
226                    .unwrap_or(false)
227            })
228            .unwrap_or(false);
229
230        let extract_to_parent = self
231            .gettext_config_or_err()
232            .map(|gettext_config| gettext_config.extract_to_parent)
233            .unwrap_or(false);
234
235        parent_extract_to_subcrate && extract_to_parent
236    }
237
238    /// Attempt to resolve the parents of this crate which have this
239    /// crate listed as a subcrate in their i18n config.
240    pub fn find_parent(&self) -> Option<Crate<'a>> {
241        let parent_crt = match self
242            .path
243            .canonicalize()
244            .map(|op| op.parent().map(|p| p.to_path_buf()))
245            .ok()
246            .unwrap_or(None)
247        {
248            Some(parent_path) => match Crate::from(parent_path, None, "i18n.toml") {
249                Ok(parent_crate) => {
250                    debug!("Found parent ({0}) of {1}.", parent_crate, self);
251                    Some(parent_crate)
252                }
253                Err(err) => {
254                    match err {
255                        I18nConfigError::NotACrate(path, WhyNotCrate::Workspace) => {
256                            debug!("The parent of {0} at path {1:?} is a workspace", self, path);
257                        }
258                        I18nConfigError::NotACrate(path, WhyNotCrate::NoCargoToml) => {
259                            debug!("The parent of {0} at path {1:?} is not a valid crate with a Cargo.toml", self, path);
260                        }
261                        _ => {
262                            error!(
263                                "Error occurred while attempting to resolve parent of {0}: {1}",
264                                self, err
265                            );
266                        }
267                    }
268
269                    None
270                }
271            },
272            None => None,
273        };
274
275        match parent_crt {
276            Some(crt) => match &crt.i18n_config {
277                Some(config) => {
278                    let this_is_subcrate = config
279                        .subcrates
280                        .iter()
281                        .any(|subcrate_path| {
282                            let subcrate_path_canon = match crt.path.join(subcrate_path).canonicalize() {
283                                Ok(canon) => canon,
284                                Err(err) => {
285                                    error!("Error: unable to canonicalize the subcrate path: {0:?} because {1}", subcrate_path, err);
286                                    return false;
287                                }
288                            };
289
290                            let self_path_canon = match self.path.canonicalize() {
291                                Ok(canon) => canon,
292                                Err(err) => {
293                                    error!("Error: unable to canonicalize the crate path: {0:?} because {1}", self.path, err);
294                                    return false;
295                                }
296                            };
297
298                            subcrate_path_canon == self_path_canon
299                        });
300
301                    if this_is_subcrate {
302                        Some(crt)
303                    } else {
304                        debug!("Parent {0} does not have {1} correctly listed as one of its subcrates (currently: {2:?}) in its i18n config.", crt, self, config.subcrates);
305                        None
306                    }
307                }
308                None => {
309                    debug!("Parent {0} of {1} does not have an i18n config", crt, self);
310                    None
311                }
312            },
313            None => {
314                debug!("Could not find a valid parent of {0}.", self);
315                None
316            }
317        }
318    }
319}
320
321impl<'a> Display for Crate<'a> {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        write!(
324            f,
325            "Crate \"{0}\" at \"{1}\"",
326            self.name,
327            self.path.to_string_lossy()
328        )
329    }
330}
331
332/// The data structure representing what is stored (and possible to
333/// store) within a `i18n.toml` file.
334#[derive(Deserialize, Debug, Clone)]
335pub struct I18nConfig {
336    /// The locale identifier of the language used in the source code
337    /// for `gettext` system, and the primary fallback language (for
338    /// which all strings must be present) when using the `fluent`
339    /// system.
340    pub fallback_language: LanguageIdentifier,
341    /// Specify which subcrates to perform localization within. The
342    /// subcrate needs to have its own `i18n.toml`.
343    #[serde(default)]
344    pub subcrates: Vec<PathBuf>,
345    /// The subcomponent of this config relating to gettext, only
346    /// present if the gettext localization system will be used.
347    pub gettext: Option<GettextConfig>,
348    /// The subcomponent of this config relating to gettext, only
349    /// present if the fluent localization system will be used.
350    pub fluent: Option<FluentConfig>,
351}
352
353impl I18nConfig {
354    /// Load the config from the specified toml file path.
355    pub fn from_file<P: AsRef<Path>>(toml_path: P) -> Result<I18nConfig, I18nConfigError> {
356        let toml_path_final: &Path = toml_path.as_ref();
357        let toml_str = read_to_string(toml_path_final).map_err(|err| {
358            I18nConfigError::CannotReadFile(
359                toml_path_final.to_path_buf(),
360                std::env::current_dir(),
361                err,
362            )
363        })?;
364        let config: I18nConfig = basic_toml::from_str(toml_str.as_ref()).map_err(|err| {
365            I18nConfigError::CannotDeserializeToml(toml_path_final.to_path_buf(), err)
366        })?;
367
368        Ok(config)
369    }
370}
371
372/// Important i18n-config paths related to the current crate.
373pub struct CratePaths {
374    /// The current crate directory path (where the `Cargo.toml` is
375    /// located).
376    pub crate_dir: PathBuf,
377    /// The current i18n config file path
378    pub i18n_config_file: PathBuf,
379}
380
381/// Locate the current crate's directory and `i18n.toml` config file.
382/// This is intended to be called by a procedural macro during crate
383/// compilation.
384pub fn locate_crate_paths() -> Result<CratePaths, I18nConfigError> {
385    let crate_dir = Path::new(
386        &std::env::var_os("CARGO_MANIFEST_DIR")
387            .ok_or(I18nConfigError::CannotReadCargoManifestDir)?,
388    )
389    .to_path_buf();
390    let i18n_config_file = crate_dir.join("i18n.toml");
391
392    Ok(CratePaths {
393        crate_dir,
394        i18n_config_file,
395    })
396}