Skip to main content

lingora_cli/
app.rs

1use std::{fs, io, path::Path};
2
3use lingora_core::prelude::*;
4
5use crate::{args::CliArgs, error::CliError};
6
7/// High-level application context for `lingora-cli`.
8///
9/// `App` owns:
10/// - the loaded configuration (`LingoraToml`)
11/// - the result of the full audit (`AuditResult`)
12///
13/// This struct acts as the bridge between parsed CLI arguments, core engine execution,
14/// and output/rendering logic.
15pub struct App {
16    settings: LingoraToml,
17    audit_result: AuditResult,
18}
19
20impl App {
21    /// Renders the audit report using `AnalysisRenderer` to the given writer.
22    ///
23    /// This is the primary way to produce the human-readable report in standard mode.
24    /// The renderer groups issues hierarchically (workspace → canonical → primaries → variants → orphans).
25    ///
26    /// # Errors
27    /// Returns `CliError::Io` if writing to the output fails.
28    pub fn output_audit_report<W: io::Write>(&self, out: &mut W) -> Result<(), CliError> {
29        let renderer = AnalysisRenderer::new(&self.audit_result);
30        renderer.render(out)?;
31        Ok(())
32    }
33
34    /// Generates `dioxus_i18n::I18nConfig` Rust code and writes it to the specified file.
35    ///
36    /// - Uses `DioxusI18nConfigRenderer` with the current `settings` and `workspace`
37    /// - Computes relative paths from the **parent directory** of the target file
38    ///   (so `include_str!` or `PathBuf::from` paths are correct relative to the generated file)
39    /// - Creates the file (fails if it already exists — use `create_new` for safety)
40    ///
41    /// # Arguments
42    /// * `path` — destination path (e.g. `src/i18n_config.rs`)
43    ///
44    /// # Errors
45    /// - `CliError::Io` on file creation/write failure
46    /// - Propagates any renderer errors (rare, usually path resolution)
47    pub fn output_dioxus_i18n_config(&self, path: &Path) -> Result<(), CliError> {
48        let base_path = path.parent();
49        let mut file = fs::File::create_new(path)?;
50        let workspace = self.audit_result.workspace();
51        let renderer = DioxusI18nConfigRenderer::new(&self.settings, workspace, base_path);
52        renderer.render(&mut file)?;
53        Ok(())
54    }
55
56    /// Returns `Ok(())` if the audit found **no issues**, otherwise returns
57    /// `Err(CliError::IntegrityErrorsDetected)`.
58    ///
59    /// Determines the final exit code in `main`:
60    /// - `0` → everything is perfect
61    /// - non-zero → issues were found (even if parsing/execution succeeded)
62    pub fn exit_status(&self) -> Result<(), CliError> {
63        if self.audit_result.is_ok() {
64            Ok(())
65        } else {
66            Err(CliError::IntegrityErrorsDetected)
67        }
68    }
69}
70
71impl TryFrom<&LingoraToml> for App {
72    type Error = CliError;
73
74    fn try_from(settings: &LingoraToml) -> Result<Self, Self::Error> {
75        let settings = settings.clone();
76        let engine = AuditEngine::try_from(&settings)?;
77        let audit_result = engine.run()?;
78
79        Ok(Self {
80            settings,
81            audit_result,
82        })
83    }
84}
85
86impl TryFrom<&CliArgs> for App {
87    type Error = CliError;
88
89    fn try_from(value: &CliArgs) -> Result<Self, Self::Error> {
90        let settings = LingoraToml::try_from(value.core_args())?;
91        Self::try_from(&settings)
92    }
93}
94
95#[cfg(test)]
96mod test {
97    use std::{env, fs, str::FromStr};
98
99    use tempfile::TempPath;
100
101    use super::*;
102
103    fn do_output_analysis(settings: &LingoraToml) -> String {
104        let out_buffer = Vec::new();
105        let mut out = io::BufWriter::new(out_buffer);
106
107        let app = App::try_from(settings).unwrap();
108
109        app.output_audit_report(&mut out).unwrap();
110
111        let bytes = out.buffer();
112        String::from_utf8_lossy(bytes).to_string()
113    }
114
115    fn with_filters(f: impl FnOnce()) {
116        let mut settings = insta::Settings::clone_current();
117        let manifest_dir = regex::escape(env!("CARGO_MANIFEST_DIR"));
118        let manifest_dir = Path::new(&manifest_dir)
119            .parent()
120            .expect("require CARGO_MANIFEST_DIR parent")
121            .display()
122            .to_string();
123        settings.add_filter(&manifest_dir, "...");
124        settings.bind(f)
125    }
126
127    #[test]
128    fn app_will_output_checks_when_no_errors() {
129        let settings = LingoraToml::from_str(
130            r#"
131[lingora]
132fluent_sources = ["../core/tests/data/i18n/en", "../core/tests/data/i18n/it"]
133canonical = "en-GB"
134primaries = ["it-IT"]
135"#,
136        )
137        .unwrap();
138
139        let result = do_output_analysis(&settings);
140
141        with_filters(|| {
142            insta::assert_snapshot!(result, @r"
143            Language:  en
144            Canonical: en-GB - Ok
145            Variant:   en-AU - Ok
146            Language:  it
147            Primary:   it-IT - Ok
148            ");
149        })
150    }
151
152    #[test]
153    fn app_will_output_checks_when_errors() {
154        let settings = LingoraToml::from_str(
155            r#"
156[lingora]
157fluent_sources = ["../core/tests/data/i18n"]
158canonical = "en-GB"
159primaries = ["fr-FR", "it-IT", "sr-Cyrl-RS"]
160"#,
161        )
162        .unwrap();
163
164        let result = do_output_analysis(&settings);
165
166        with_filters(|| {
167            insta::assert_snapshot!(result, @r"
168            Language:  en
169            Canonical: en-GB - Ok
170            Variant:   en-AU - Ok
171            Language:  fr
172            Primary:   fr-FR
173                       missing translation 'en'
174                       missing translation 'en-AU'
175                       missing translation 'en-GB'
176            Language:  it
177            Primary:   it-IT - Ok
178            Language:  sr
179            Primary:   sr-Cyrl-RS
180                       missing translation 'en-GB'
181                       redundant translation '-en-GB'
182            Variant:   sr-Cyrl-BA - Ok
183            ");
184        });
185    }
186
187    fn create_temp_filepath() -> TempPath {
188        let file = tempfile::NamedTempFile::new().unwrap();
189
190        let temp_path = file.into_temp_path();
191        let path = temp_path.to_path_buf();
192        fs::remove_file(&path).expect("temporary file must be deleted");
193
194        temp_path
195    }
196
197    #[test]
198    fn will_output_dioxus_i18n_config_for_auto() {
199        let settings = LingoraToml::from_str(
200            r#"
201[lingora]
202fluent_sources = ["../core/tests/data/i18n_semantic"]
203canonical = "en-GB"
204primaries = ["fr-FR", "it-IT", "sr-Cyrl-RS"]
205[dioxus_i18n]
206config_inclusion = "auto"
207"#,
208        )
209        .unwrap();
210
211        let path = create_temp_filepath();
212        let app = App::try_from(&settings).unwrap();
213        app.output_dioxus_i18n_config(&path).unwrap();
214
215        let content = fs::read_to_string(path).unwrap();
216
217        with_filters(|| {
218            insta::assert_snapshot!(content, @r#"
219            use dioxus_i18n::{prelude::*, *};
220            use unic_langid::{langid, LanguageIdentifier};
221            use std::path::PathBuf;
222
223            pub fn config(initial_language: LanguageIdentifier) -> I18nConfig {
224                I18nConfig::new(initial_language)
225                    .with_auto_locales(PathBuf::from(".../core/tests/data/i18n_semantic"))
226                    .with_locale(langid!("en"), PathBuf::from(".../core/tests/data/i18n_semantic/en/en-GB/errors.ftl"))
227                    .with_locale(langid!("it"), PathBuf::from(".../core/tests/data/i18n_semantic/it/it-IT/errors.ftl"))
228                    .with_fallback(langid!("en-GB"))
229            }
230            "#);
231        });
232    }
233
234    #[test]
235    #[cfg(not(target_os = "windows"))]
236    fn will_output_dioxus_i18n_config_for_pathbuf() {
237        let settings = LingoraToml::from_str(
238            r#"
239[lingora]
240fluent_sources = ["../core/tests/data/i18n_semantic"]
241canonical = "en-GB"
242primaries = ["fr-FR", "it-IT", "sr-Cyrl-RS"]
243[dioxus_i18n]
244config_inclusion = "pathbuf"
245"#,
246        )
247        .unwrap();
248
249        let path = create_temp_filepath();
250        let app = App::try_from(&settings).unwrap();
251        app.output_dioxus_i18n_config(&path).unwrap();
252
253        let content = fs::read_to_string(path).unwrap();
254
255        with_filters(|| {
256            insta::assert_snapshot!(content, @r#"
257            use dioxus_i18n::{prelude::*, *};
258            use unic_langid::{langid, LanguageIdentifier};
259            use std::path::PathBuf;
260
261            pub fn config(initial_language: LanguageIdentifier) -> I18nConfig {
262                I18nConfig::new(initial_language)
263                    .with_locale((
264                        langid!("en-AU"),
265                        PathBuf::from(".../core/tests/data/i18n_semantic/en/en-AU/errors.ftl")
266                    ))
267                    .with_locale((
268                        langid!("en-GB"),
269                        PathBuf::from(".../core/tests/data/i18n_semantic/en/en-GB/errors.ftl")
270                    ))
271                    .with_locale((
272                        langid!("it-IT"),
273                        PathBuf::from(".../core/tests/data/i18n_semantic/it/it-IT/errors.ftl")
274                    ))
275                    .with_locale(langid!("en"), PathBuf::from(".../core/tests/data/i18n_semantic/en/en-GB/errors.ftl"))
276                    .with_locale(langid!("it"), PathBuf::from(".../core/tests/data/i18n_semantic/it/it-IT/errors.ftl"))
277                    .with_fallback(langid!("en-GB"))
278            }
279            "#);
280        });
281    }
282
283    #[test]
284    #[cfg(not(target_os = "windows"))]
285    fn will_output_dioxus_i18n_config_for_include_str() {
286        let settings = LingoraToml::from_str(
287            r#"
288[lingora]
289fluent_sources = ["../core/tests/data/i18n_semantic"]
290canonical = "en-GB"
291primaries = ["fr-FR", "it-IT", "sr-Cyrl-RS"]
292[dioxus_i18n]
293config_inclusion = "includestr"
294"#,
295        )
296        .unwrap();
297
298        let path = create_temp_filepath();
299        let app = App::try_from(&settings).unwrap();
300        app.output_dioxus_i18n_config(&path).unwrap();
301
302        let content = fs::read_to_string(path).unwrap();
303
304        with_filters(|| {
305            insta::assert_snapshot!(content, @r#"
306            use dioxus_i18n::{prelude::*, *};
307            use unic_langid::{langid, LanguageIdentifier};
308
309
310            pub fn config(initial_language: LanguageIdentifier) -> I18nConfig {
311                I18nConfig::new(initial_language)
312                    .with_locale((
313                        langid!("en-AU"),
314                        include_str!(".../core/tests/data/i18n_semantic/en/en-AU/errors.ftl")
315                    ))
316                    .with_locale((
317                        langid!("en-GB"),
318                        include_str!(".../core/tests/data/i18n_semantic/en/en-GB/errors.ftl")
319                    ))
320                    .with_locale((
321                        langid!("it-IT"),
322                        include_str!(".../core/tests/data/i18n_semantic/it/it-IT/errors.ftl")
323                    ))
324                    .with_locale(langid!("en"), include_str!(".../core/tests/data/i18n_semantic/en/en-GB/errors.ftl"))
325                    .with_locale(langid!("it"), include_str!(".../core/tests/data/i18n_semantic/it/it-IT/errors.ftl"))
326                    .with_fallback(langid!("en-GB"))
327            }
328            "#);
329        });
330    }
331
332    #[test]
333    #[cfg(not(target_os = "windows"))]
334    fn will_output_dioxus_i18n_config_shares_for_pathbuf() {
335        let settings = LingoraToml::from_str(
336            r#"
337[lingora]
338fluent_sources = ["../core/tests/data/i18n_semantic"]
339canonical = "en-GB"
340primaries = ["fr-FR", "it-IT", "sr-Cyrl-RS"]
341[dioxus_i18n]
342config_inclusion = "pathbuf"
343"#,
344        )
345        .unwrap();
346
347        let path = create_temp_filepath();
348        let app = App::try_from(&settings).unwrap();
349        app.output_dioxus_i18n_config(&path).unwrap();
350
351        let content = fs::read_to_string(path).unwrap();
352
353        with_filters(|| {
354            insta::assert_snapshot!(content, @r#"
355            use dioxus_i18n::{prelude::*, *};
356            use unic_langid::{langid, LanguageIdentifier};
357            use std::path::PathBuf;
358
359            pub fn config(initial_language: LanguageIdentifier) -> I18nConfig {
360                I18nConfig::new(initial_language)
361                    .with_locale((
362                        langid!("en-AU"),
363                        PathBuf::from(".../core/tests/data/i18n_semantic/en/en-AU/errors.ftl")
364                    ))
365                    .with_locale((
366                        langid!("en-GB"),
367                        PathBuf::from(".../core/tests/data/i18n_semantic/en/en-GB/errors.ftl")
368                    ))
369                    .with_locale((
370                        langid!("it-IT"),
371                        PathBuf::from(".../core/tests/data/i18n_semantic/it/it-IT/errors.ftl")
372                    ))
373                    .with_locale(langid!("en"), PathBuf::from(".../core/tests/data/i18n_semantic/en/en-GB/errors.ftl"))
374                    .with_locale(langid!("it"), PathBuf::from(".../core/tests/data/i18n_semantic/it/it-IT/errors.ftl"))
375                    .with_fallback(langid!("en-GB"))
376            }
377            "#);
378        });
379    }
380
381    #[test]
382    #[cfg(not(target_os = "windows"))]
383    fn will_output_dioxus_i18n_config_shares_for_include_str() {
384        let settings = LingoraToml::from_str(
385            r#"
386[lingora]
387fluent_sources = ["../core/tests/data/i18n_semantic"]
388canonical = "en-GB"
389primaries = ["fr-FR", "it-IT", "sr-Cyrl-RS"]
390[dioxus_i18n]
391config_inclusion = "includestr"
392"#,
393        )
394        .unwrap();
395
396        let path = create_temp_filepath();
397        let app = App::try_from(&settings).unwrap();
398        app.output_dioxus_i18n_config(&path).unwrap();
399
400        let content = fs::read_to_string(path).unwrap();
401
402        with_filters(|| {
403            insta::assert_snapshot!(content, @r#"
404            use dioxus_i18n::{prelude::*, *};
405            use unic_langid::{langid, LanguageIdentifier};
406
407
408            pub fn config(initial_language: LanguageIdentifier) -> I18nConfig {
409                I18nConfig::new(initial_language)
410                    .with_locale((
411                        langid!("en-AU"),
412                        include_str!(".../core/tests/data/i18n_semantic/en/en-AU/errors.ftl")
413                    ))
414                    .with_locale((
415                        langid!("en-GB"),
416                        include_str!(".../core/tests/data/i18n_semantic/en/en-GB/errors.ftl")
417                    ))
418                    .with_locale((
419                        langid!("it-IT"),
420                        include_str!(".../core/tests/data/i18n_semantic/it/it-IT/errors.ftl")
421                    ))
422                    .with_locale(langid!("en"), include_str!(".../core/tests/data/i18n_semantic/en/en-GB/errors.ftl"))
423                    .with_locale(langid!("it"), include_str!(".../core/tests/data/i18n_semantic/it/it-IT/errors.ftl"))
424                    .with_fallback(langid!("en-GB"))
425            }
426            "#);
427        });
428    }
429}