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
18pub fn config() -> RosettaBuilder {
20 RosettaBuilder::default()
21}
22
23#[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 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 pub fn fallback(mut self, lang: impl Into<String>) -> Self {
41 self.fallback = Some(lang.into());
42 self
43 }
44
45 pub fn name(mut self, name: impl Into<String>) -> Self {
47 self.name = Some(name.into());
48 self
49 }
50
51 pub fn output(mut self, path: impl Into<PathBuf>) -> Self {
53 self.output = Some(path.into());
54 self
55 }
56
57 pub fn generate(self) -> Result<(), BuildError> {
59 self.build()?.generate()?;
60 Ok(())
61 }
62
63 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#[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#[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 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 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
185fn 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#[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}