rosetta_build/
builder.rs

1use std::{
2    collections::HashMap,
3    env,
4    fmt::{self, Display},
5    fs::File,
6    io::Write,
7    path::{Path, PathBuf},
8    str::FromStr,
9};
10
11use tinyjson::JsonValue;
12
13use crate::{
14    error::{BuildError, ConfigError},
15    gen, parser,
16};
17
18/// Helper function that return an default [`RosettaBuilder`].
19pub fn config() -> RosettaBuilder {
20    RosettaBuilder::default()
21}
22
23/// Builder used to configure Rosetta code generation.
24#[derive(Debug, Clone, PartialEq, Eq, Default)]
25pub struct RosettaBuilder {
26    files: HashMap<String, PathBuf>,
27    fallback: Option<String>,
28    name: Option<String>,
29    output: Option<PathBuf>,
30}
31
32impl RosettaBuilder {
33    /// Register a new translation source
34    pub fn source(mut self, lang: impl Into<String>, path: impl Into<String>) -> Self {
35        self.files.insert(lang.into(), PathBuf::from(path.into()));
36        self
37    }
38
39    /// Register the fallback locale
40    pub fn fallback(mut self, lang: impl Into<String>) -> Self {
41        self.fallback = Some(lang.into());
42        self
43    }
44
45    /// Define a custom name for the output type
46    pub fn name(mut self, name: impl Into<String>) -> Self {
47        self.name = Some(name.into());
48        self
49    }
50
51    /// Change the default output of generated files
52    pub fn output(mut self, path: impl Into<PathBuf>) -> Self {
53        self.output = Some(path.into());
54        self
55    }
56
57    /// Generate locale files and write them to the output location
58    pub fn generate(self) -> Result<(), BuildError> {
59        self.build()?.generate()?;
60        Ok(())
61    }
62
63    /// Validate configuration and build a [`RosettaConfig`]
64    fn build(self) -> Result<RosettaConfig, ConfigError> {
65        let mut files: HashMap<LanguageId, PathBuf> = self
66            .files
67            .into_iter()
68            .map(|(lang, path)| {
69                let lang = lang.parse::<LanguageId>()?;
70                Ok((lang, path))
71            })
72            .collect::<Result<_, _>>()?;
73
74        if files.is_empty() {
75            return Err(ConfigError::MissingSource);
76        }
77
78        let fallback = match self.fallback {
79            Some(lang) => {
80                let lang = lang.parse::<LanguageId>()?;
81
82                match files.remove_entry(&lang) {
83                    Some(entry) => entry,
84                    None => return Err(ConfigError::InvalidFallback),
85                }
86            }
87            None => return Err(ConfigError::MissingFallback),
88        };
89
90        Ok(RosettaConfig {
91            fallback,
92            others: files,
93            name: self.name.unwrap_or_else(|| "Lang".to_string()),
94            output: self.output,
95        })
96    }
97}
98
99/// ISO 639-1 language identifier.
100///
101/// Language identifier can be validated using the [`FromStr`] trait.
102/// It only checks if the string *looks like* a language identifier (2 character alphanumeric ascii string).
103#[derive(Debug, Clone, PartialEq, Eq, Hash)]
104pub(crate) struct LanguageId(pub String);
105
106impl LanguageId {
107    pub(crate) fn value(&self) -> &str {
108        &self.0
109    }
110}
111
112impl FromStr for LanguageId {
113    type Err = ConfigError;
114
115    fn from_str(s: &str) -> Result<Self, Self::Err> {
116        let valid_length = s.len() == 2;
117        let ascii_alphabetic = s.chars().all(|c| c.is_ascii_alphabetic());
118
119        if valid_length && ascii_alphabetic {
120            Ok(Self(s.to_ascii_lowercase()))
121        } else {
122            Err(ConfigError::InvalidLanguage(s.into()))
123        }
124    }
125}
126
127impl Display for LanguageId {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        write!(f, "{}", self.0)
130    }
131}
132
133/// Configuration for Rosetta code generation
134///
135/// A [`RosettaBuilder`] is provided to construct and validate configuration.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub(crate) struct RosettaConfig {
138    pub fallback: (LanguageId, PathBuf),
139    pub others: HashMap<LanguageId, PathBuf>,
140    pub name: String,
141    pub output: Option<PathBuf>,
142}
143
144impl RosettaConfig {
145    /// Returns a list of the languages
146    pub fn languages(&self) -> Vec<&LanguageId> {
147        let mut languages: Vec<&LanguageId> =
148            self.others.iter().map(|(language, _)| language).collect();
149        languages.push(&self.fallback.0);
150        languages
151    }
152
153    /// Generate locale files and write them to the output location
154    pub fn generate(&self) -> Result<(), BuildError> {
155        let fallback_content = open_file(&self.fallback.1)?;
156        let mut parsed = parser::TranslationData::from_fallback(fallback_content)?;
157        println!(
158            "cargo:rerun-if-changed={}",
159            self.fallback.1.to_string_lossy()
160        );
161
162        for (language, path) in &self.others {
163            let content = open_file(path)?;
164            parsed.parse_file(language.clone(), content)?;
165            println!("cargo:rerun-if-changed={}", path.to_string_lossy());
166        }
167
168        let generated = gen::CodeGenerator::new(&parsed, self).generate();
169
170        let output = match &self.output {
171            Some(path) => path.clone(),
172            None => Path::new(&env::var("OUT_DIR")?).join("rosetta_output.rs"),
173        };
174
175        let mut file = File::create(&output)?;
176        file.write_all(generated.to_string().as_bytes())?;
177
178        #[cfg(feature = "rustfmt")]
179        rustfmt(&output)?;
180
181        Ok(())
182    }
183}
184
185/// Open a file and read its content as a JSON [`JsonValue`]
186fn open_file(path: &Path) -> Result<JsonValue, BuildError> {
187    let content = match std::fs::read_to_string(path) {
188        Ok(content) => content,
189        Err(error) => {
190            return Err(BuildError::FileRead {
191                file: path.to_path_buf(),
192                source: error,
193            })
194        }
195    };
196
197    match content.parse::<JsonValue>() {
198        Ok(parsed) => Ok(parsed),
199        Err(error) => Err(BuildError::JsonParse {
200            file: path.to_path_buf(),
201            source: error,
202        }),
203    }
204}
205
206/// Format a file with rustfmt
207#[cfg(feature = "rustfmt")]
208fn rustfmt(path: &Path) -> Result<(), BuildError> {
209    use std::process::Command;
210
211    Command::new(env::var("RUSTFMT").unwrap_or_else(|_| "rustfmt".to_string()))
212        .args(&["--emit", "files"])
213        .arg(path)
214        .output()
215        .map_err(BuildError::Fmt)?;
216
217    Ok(())
218}
219
220#[cfg(test)]
221mod tests {
222    use super::RosettaConfig;
223    use crate::{
224        builder::{LanguageId, RosettaBuilder},
225        error::ConfigError,
226    };
227
228    use std::path::PathBuf;
229
230    use maplit::hashmap;
231
232    #[test]
233    fn config_simple() -> Result<(), Box<dyn std::error::Error>> {
234        let config = RosettaBuilder::default()
235            .source("en", "translations/en.json")
236            .source("fr", "translations/fr.json")
237            .fallback("en")
238            .build()?;
239
240        let expected = RosettaConfig {
241            fallback: (
242                LanguageId("en".into()),
243                PathBuf::from("translations/en.json"),
244            ),
245            others: hashmap! { LanguageId("fr".into()) => PathBuf::from("translations/fr.json") },
246            name: "Lang".to_string(),
247            output: None,
248        };
249
250        assert_eq!(config, expected);
251
252        Ok(())
253    }
254
255    #[test]
256    fn config_missing_source() {
257        let config = RosettaBuilder::default().build();
258        assert_eq!(config, Err(ConfigError::MissingSource));
259    }
260
261    #[test]
262    fn config_invalid_language() {
263        let config = RosettaBuilder::default()
264            .source("en", "translations/en.json")
265            .source("invalid", "translations/fr.json")
266            .fallback("en")
267            .build();
268
269        assert_eq!(
270            config,
271            Err(ConfigError::InvalidLanguage("invalid".to_string()))
272        );
273    }
274
275    #[test]
276    fn config_missing_fallback() {
277        let config = RosettaBuilder::default()
278            .source("en", "translations/en.json")
279            .source("fr", "translations/fr.json")
280            .build();
281
282        assert_eq!(config, Err(ConfigError::MissingFallback));
283    }
284
285    #[test]
286    fn config_invalid_fallback() {
287        let config = RosettaBuilder::default()
288            .source("en", "translations/en.json")
289            .source("fr", "translations/fr.json")
290            .fallback("de")
291            .build();
292
293        assert_eq!(config, Err(ConfigError::InvalidFallback));
294    }
295}