Skip to main content

langcodec_cli/
translate.rs

1use crate::validation::{validate_language_code, validate_output_path};
2use crate::{
3    ai::{ProviderKind, build_provider, resolve_model, resolve_provider},
4    config::{LoadedConfig, load_config, resolve_config_relative_path},
5    path_glob,
6    tolgee::{
7        TranslateTolgeeContext, TranslateTolgeeSettings, prefill_translate_from_tolgee,
8        push_translate_results_to_tolgee,
9    },
10    tui::{
11        DashboardEvent, DashboardInit, DashboardItem, DashboardItemStatus, DashboardKind,
12        DashboardLogTone, PlainReporter, ResolvedUiMode, RunReporter, SummaryRow, TuiReporter,
13        UiMode, resolve_ui_mode_for_current_terminal,
14    },
15};
16use async_trait::async_trait;
17use langcodec::{
18    Codec, Entry, EntryStatus, FormatType, Metadata, ReadOptions, Resource, Translation,
19    convert_resources_to_format,
20    formats::{AndroidStringsFormat, CSVFormat, StringsFormat, TSVFormat, XcstringsFormat},
21    infer_format_from_extension, infer_language_from_path,
22    traits::Parser,
23};
24use mentra::provider::{
25    self, ContentBlock, Message, Provider, ProviderError, ProviderRequestOptions, Request,
26};
27use serde::Deserialize;
28use std::{
29    borrow::Cow,
30    collections::{BTreeMap, HashMap, VecDeque},
31    path::{Path, PathBuf},
32    sync::Arc,
33    thread,
34};
35use tokio::{
36    runtime::Builder,
37    sync::{Mutex as AsyncMutex, mpsc},
38    task::JoinSet,
39};
40
41const DEFAULT_STATUSES: [&str; 2] = ["new", "stale"];
42const DEFAULT_CONCURRENCY: usize = 4;
43const SYSTEM_PROMPT: &str = "You translate application localization strings. Return JSON only with the shape {\"translation\":\"...\"}. Preserve placeholders, escapes, newline markers, surrounding punctuation, HTML/XML tags, Markdown, and product names exactly unless the target language grammar requires adjacent spacing changes. Never add explanations or extra keys.";
44
45#[derive(Debug, Clone)]
46pub struct TranslateOptions {
47    pub source: Option<String>,
48    pub target: Option<String>,
49    pub output: Option<String>,
50    pub source_lang: Option<String>,
51    pub target_langs: Vec<String>,
52    pub status: Option<String>,
53    pub provider: Option<String>,
54    pub model: Option<String>,
55    pub concurrency: Option<usize>,
56    pub config: Option<String>,
57    pub use_tolgee: bool,
58    pub tolgee_config: Option<String>,
59    pub tolgee_namespaces: Vec<String>,
60    pub dry_run: bool,
61    pub strict: bool,
62    pub ui_mode: UiMode,
63}
64
65#[derive(Debug, Clone)]
66struct ResolvedOptions {
67    source: String,
68    target: Option<String>,
69    output: Option<String>,
70    source_lang: Option<String>,
71    target_langs: Vec<String>,
72    statuses: Vec<EntryStatus>,
73    output_status: EntryStatus,
74    provider: Option<ProviderKind>,
75    model: Option<String>,
76    provider_error: Option<String>,
77    model_error: Option<String>,
78    concurrency: usize,
79    use_tolgee: bool,
80    tolgee_config: Option<String>,
81    tolgee_namespaces: Vec<String>,
82    dry_run: bool,
83    strict: bool,
84    ui_mode: ResolvedUiMode,
85}
86
87#[derive(Debug, Clone)]
88struct SelectedResource {
89    language: String,
90    resource: Resource,
91}
92
93#[derive(Debug, Clone)]
94struct TranslationJob {
95    key: String,
96    source_lang: String,
97    target_lang: String,
98    source_value: String,
99    source_comment: Option<String>,
100    existing_comment: Option<String>,
101}
102
103#[derive(Debug, Default, Clone)]
104struct TranslationSummary {
105    total_entries: usize,
106    queued: usize,
107    translated: usize,
108    skipped_do_not_translate: usize,
109    skipped_plural: usize,
110    skipped_status: usize,
111    skipped_empty_source: usize,
112    failed: usize,
113}
114
115#[derive(Debug, Clone)]
116struct TranslationResult {
117    key: String,
118    target_lang: String,
119    translated_value: String,
120}
121
122#[allow(dead_code)]
123#[derive(Debug, Clone)]
124pub struct TranslateOutcome {
125    pub translated: usize,
126    pub skipped: usize,
127    pub failed: usize,
128    pub output_path: Option<String>,
129}
130
131#[derive(Debug, Clone)]
132struct PreparedTranslation {
133    opts: ResolvedOptions,
134    source_path: String,
135    target_path: String,
136    output_path: String,
137    output_format: FormatType,
138    config_path: Option<PathBuf>,
139    source_resource: SelectedResource,
140    target_codec: Codec,
141    tolgee_context: Option<TranslateTolgeeContext>,
142    jobs: Vec<TranslationJob>,
143    summary: TranslationSummary,
144}
145
146#[derive(Clone)]
147struct MentraBackend {
148    provider: Arc<dyn Provider>,
149    model: String,
150}
151
152#[derive(Debug, Clone)]
153struct BackendRequest {
154    key: String,
155    source_lang: String,
156    target_lang: String,
157    source_value: String,
158    source_comment: Option<String>,
159}
160
161enum TranslationWorkerUpdate {
162    Started {
163        id: String,
164    },
165    Finished {
166        id: String,
167        result: Result<TranslationResult, String>,
168    },
169}
170
171#[derive(Debug, Clone, Deserialize)]
172struct ModelTranslationPayload {
173    translation: String,
174}
175
176#[async_trait]
177trait TranslationBackend: Send + Sync {
178    async fn translate(&self, request: BackendRequest) -> Result<String, String>;
179}
180
181#[async_trait]
182impl TranslationBackend for MentraBackend {
183    async fn translate(&self, request: BackendRequest) -> Result<String, String> {
184        let prompt = build_prompt(&request);
185        let response = self
186            .provider
187            .send(Request {
188                model: Cow::Borrowed(self.model.as_str()),
189                system: Some(Cow::Borrowed(SYSTEM_PROMPT)),
190                messages: Cow::Owned(vec![Message::user(ContentBlock::text(prompt))]),
191                tools: Cow::Owned(Vec::new()),
192                tool_choice: None,
193                temperature: Some(0.2),
194                max_output_tokens: Some(512),
195                metadata: Cow::Owned(BTreeMap::new()),
196                provider_request_options: ProviderRequestOptions::default(),
197            })
198            .await
199            .map_err(format_provider_error)?;
200
201        let text = collect_text_blocks(&response);
202        parse_translation_response(&text)
203    }
204}
205
206pub fn run_translate_command(opts: TranslateOptions) -> Result<TranslateOutcome, String> {
207    let runs = expand_translate_invocations(&opts)?;
208    if runs.len() > 1 && matches!(opts.ui_mode, UiMode::Tui) {
209        return Err("TUI mode supports only one translate run at a time".to_string());
210    }
211    if runs.len() == 1 {
212        return run_single_translate_command(runs.into_iter().next().unwrap());
213    }
214
215    eprintln!(
216        "Running {} translate jobs in parallel from config",
217        runs.len()
218    );
219
220    let mut handles = Vec::new();
221    for mut run in runs {
222        run.ui_mode = UiMode::Plain;
223        handles.push(thread::spawn(move || run_single_translate_command(run)));
224    }
225
226    let mut translated = 0usize;
227    let mut skipped = 0usize;
228    let mut failed = 0usize;
229    let mut first_error = None;
230
231    for handle in handles {
232        match handle.join() {
233            Ok(Ok(outcome)) => {
234                translated += outcome.translated;
235                skipped += outcome.skipped;
236                failed += outcome.failed;
237            }
238            Ok(Err(err)) => {
239                failed += 1;
240                if first_error.is_none() {
241                    first_error = Some(err);
242                }
243            }
244            Err(_) => {
245                failed += 1;
246                if first_error.is_none() {
247                    first_error = Some("Parallel translate worker panicked".to_string());
248                }
249            }
250        }
251    }
252
253    if let Some(err) = first_error {
254        return Err(format!(
255            "{} (translated={}, skipped={}, failed_jobs={})",
256            err, translated, skipped, failed
257        ));
258    }
259
260    Ok(TranslateOutcome {
261        translated,
262        skipped,
263        failed,
264        output_path: None,
265    })
266}
267
268fn run_single_translate_command(opts: TranslateOptions) -> Result<TranslateOutcome, String> {
269    let prepared = prepare_translation(&opts)?;
270    if prepared.jobs.is_empty() {
271        return run_prepared_translation(prepared, None);
272    }
273    let backend = create_mentra_backend(&prepared.opts)?;
274    run_prepared_translation(prepared, Some(Arc::new(backend)))
275}
276
277fn expand_translate_invocations(opts: &TranslateOptions) -> Result<Vec<TranslateOptions>, String> {
278    let loaded_config = load_config(opts.config.as_deref())?;
279    let cfg = loaded_config.as_ref().map(|item| &item.data.translate);
280    let config_path = loaded_config
281        .as_ref()
282        .map(|item| item.path.to_string_lossy().to_string())
283        .or_else(|| opts.config.clone());
284    let config_dir = loaded_config
285        .as_ref()
286        .and_then(|item| item.path.parent())
287        .map(Path::to_path_buf);
288
289    if cfg
290        .and_then(|item| item.resolved_source())
291        .is_some_and(|_| cfg.and_then(|item| item.resolved_sources()).is_some())
292    {
293        return Err(
294            "Config translate.input.source/translate.source and translate.input.sources/translate.sources cannot both be set"
295                .to_string(),
296        );
297    }
298
299    let sources = resolve_config_sources(opts, cfg, config_dir.as_deref())?;
300    if sources.is_empty() {
301        return Err(
302            "--source is required unless translate.input.source/translate.source or translate.input.sources/translate.sources is set in langcodec.toml"
303                .to_string(),
304        );
305    }
306
307    let target = if let Some(path) = &opts.target {
308        Some(path.clone())
309    } else {
310        cfg.and_then(|item| item.resolved_target())
311            .map(|path| resolve_config_relative_path(config_dir.as_deref(), path))
312    };
313    let output = if let Some(path) = &opts.output {
314        Some(path.clone())
315    } else {
316        cfg.and_then(|item| item.resolved_output_path())
317            .map(|path| resolve_config_relative_path(config_dir.as_deref(), path))
318    };
319
320    if sources.len() > 1 && (target.is_some() || output.is_some()) {
321        return Err(
322            "translate.input.sources/translate.sources cannot be combined with translate.output.target/translate.target, translate.output.path/translate.output, or CLI --target/--output; use in-place multi-language sources or invoke files individually"
323                .to_string(),
324        );
325    }
326
327    Ok(sources
328        .into_iter()
329        .map(|source| TranslateOptions {
330            source: Some(source),
331            target: target.clone(),
332            output: output.clone(),
333            source_lang: opts
334                .source_lang
335                .clone()
336                .or_else(|| cfg.and_then(|item| item.resolved_source_lang().map(str::to_string))),
337            target_langs: if opts.target_langs.is_empty() {
338                Vec::new()
339            } else {
340                opts.target_langs.clone()
341            },
342            status: opts.status.clone(),
343            provider: opts.provider.clone(),
344            model: opts.model.clone(),
345            concurrency: opts.concurrency,
346            config: config_path.clone(),
347            use_tolgee: opts.use_tolgee,
348            tolgee_config: opts.tolgee_config.clone(),
349            tolgee_namespaces: opts.tolgee_namespaces.clone(),
350            dry_run: opts.dry_run,
351            strict: opts.strict,
352            ui_mode: opts.ui_mode,
353        })
354        .collect())
355}
356
357fn resolve_config_sources(
358    opts: &TranslateOptions,
359    cfg: Option<&crate::config::TranslateConfig>,
360    config_dir: Option<&Path>,
361) -> Result<Vec<String>, String> {
362    fn has_glob_meta(path: &str) -> bool {
363        path.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
364    }
365
366    if let Some(source) = &opts.source {
367        return Ok(vec![source.clone()]);
368    }
369
370    if let Some(source) = cfg.and_then(|item| item.resolved_source()) {
371        let resolved = vec![resolve_config_relative_path(config_dir, source)];
372        return if resolved.iter().any(|path| has_glob_meta(path)) {
373            path_glob::expand_input_globs(&resolved)
374        } else {
375            Ok(resolved)
376        };
377    }
378
379    if let Some(sources) = cfg.and_then(|item| item.resolved_sources()) {
380        let resolved = sources
381            .iter()
382            .map(|source| resolve_config_relative_path(config_dir, source))
383            .collect::<Vec<_>>();
384        return if resolved.iter().any(|path| has_glob_meta(path)) {
385            path_glob::expand_input_globs(&resolved)
386        } else {
387            Ok(resolved)
388        };
389    }
390
391    Ok(Vec::new())
392}
393
394fn run_prepared_translation(
395    prepared: PreparedTranslation,
396    backend: Option<Arc<dyn TranslationBackend>>,
397) -> Result<TranslateOutcome, String> {
398    let runtime = Builder::new_multi_thread()
399        .enable_all()
400        .build()
401        .map_err(|e| format!("Failed to create async runtime: {}", e))?;
402    runtime.block_on(async_run_translation(prepared, backend))
403}
404
405async fn async_run_translation(
406    mut prepared: PreparedTranslation,
407    backend: Option<Arc<dyn TranslationBackend>>,
408) -> Result<TranslateOutcome, String> {
409    validate_translation_preflight(&prepared)?;
410    if matches!(prepared.opts.ui_mode, ResolvedUiMode::Plain) {
411        print_preamble(&prepared);
412    }
413
414    if prepared.jobs.is_empty() {
415        print_summary(&prepared.summary);
416        if prepared.opts.dry_run {
417            println!("Dry-run mode: no files were written");
418        } else {
419            write_back(
420                &prepared.target_codec,
421                &prepared.output_path,
422                &prepared.output_format,
423                single_output_language(&prepared.opts.target_langs),
424            )?;
425            println!("✅ Translate complete: {}", prepared.output_path);
426        }
427        return Ok(TranslateOutcome {
428            translated: 0,
429            skipped: count_skipped(&prepared.summary),
430            failed: 0,
431            output_path: Some(prepared.output_path),
432        });
433    }
434
435    let worker_count = prepared.opts.concurrency.min(prepared.jobs.len()).max(1);
436    let backend = backend.ok_or_else(|| {
437        "Translation backend was not configured even though jobs remain".to_string()
438    })?;
439    let mut reporter = create_translate_reporter(&prepared)?;
440    reporter.emit(DashboardEvent::Log {
441        tone: DashboardLogTone::Info,
442        message: "Preflight validation passed".to_string(),
443    });
444    reporter.emit(DashboardEvent::Log {
445        tone: DashboardLogTone::Info,
446        message: format!("Starting {} worker(s)", worker_count),
447    });
448    let queue = Arc::new(AsyncMutex::new(VecDeque::from(prepared.jobs.clone())));
449    let (tx, mut rx) = mpsc::unbounded_channel::<TranslationWorkerUpdate>();
450    let mut join_set = JoinSet::new();
451    for _ in 0..worker_count {
452        let backend = Arc::clone(&backend);
453        let queue = Arc::clone(&queue);
454        let tx = tx.clone();
455        join_set.spawn(async move {
456            loop {
457                let job = {
458                    let mut queue = queue.lock().await;
459                    queue.pop_front()
460                };
461
462                let Some(job) = job else {
463                    break;
464                };
465
466                let id = translation_job_id(&job);
467                let _ = tx.send(TranslationWorkerUpdate::Started { id: id.clone() });
468                let result = backend
469                    .translate(BackendRequest {
470                        key: job.key.clone(),
471                        source_lang: job.source_lang.clone(),
472                        target_lang: job.target_lang.clone(),
473                        source_value: job.source_value.clone(),
474                        source_comment: job.source_comment.clone(),
475                    })
476                    .await
477                    .map(|translated_value| TranslationResult {
478                        key: job.key.clone(),
479                        target_lang: job.target_lang.clone(),
480                        translated_value,
481                    });
482                let _ = tx.send(TranslationWorkerUpdate::Finished { id, result });
483            }
484
485            Ok::<(), String>(())
486        });
487    }
488    drop(tx);
489
490    let mut results: HashMap<(String, String), String> = HashMap::new();
491
492    while let Some(update) = rx.recv().await {
493        match update {
494            TranslationWorkerUpdate::Started { id } => {
495                reporter.emit(DashboardEvent::UpdateItem {
496                    id,
497                    status: Some(DashboardItemStatus::Running),
498                    subtitle: None,
499                    source_text: None,
500                    output_text: None,
501                    note_text: None,
502                    error_text: None,
503                    extra_rows: None,
504                });
505            }
506            TranslationWorkerUpdate::Finished { id, result } => match result {
507                Ok(item) => {
508                    prepared.summary.translated += 1;
509                    let translated_value = item.translated_value.clone();
510                    results.insert((item.key, item.target_lang), item.translated_value);
511                    reporter.emit(DashboardEvent::UpdateItem {
512                        id,
513                        status: Some(DashboardItemStatus::Succeeded),
514                        subtitle: None,
515                        source_text: None,
516                        output_text: Some(translated_value),
517                        note_text: None,
518                        error_text: None,
519                        extra_rows: None,
520                    });
521                }
522                Err(err) => {
523                    prepared.summary.failed += 1;
524                    reporter.emit(DashboardEvent::UpdateItem {
525                        id,
526                        status: Some(DashboardItemStatus::Failed),
527                        subtitle: None,
528                        source_text: None,
529                        output_text: None,
530                        note_text: None,
531                        error_text: Some(err.clone()),
532                        extra_rows: None,
533                    });
534                    reporter.emit(DashboardEvent::Log {
535                        tone: DashboardLogTone::Error,
536                        message: err,
537                    });
538                }
539            },
540        }
541        reporter.emit(DashboardEvent::SummaryRows {
542            rows: translation_summary_rows(&prepared.summary),
543        });
544    }
545
546    while let Some(result) = join_set.join_next().await {
547        match result {
548            Ok(Ok(())) => {}
549            Ok(Err(err)) => {
550                prepared.summary.failed += 1;
551                reporter.emit(DashboardEvent::Log {
552                    tone: DashboardLogTone::Error,
553                    message: format!("Translation worker failed: {}", err),
554                });
555            }
556            Err(err) => {
557                prepared.summary.failed += 1;
558                reporter.emit(DashboardEvent::Log {
559                    tone: DashboardLogTone::Error,
560                    message: format!("Translation task failed to join: {}", err),
561                });
562            }
563        }
564        reporter.emit(DashboardEvent::SummaryRows {
565            rows: translation_summary_rows(&prepared.summary),
566        });
567    }
568
569    if prepared.summary.failed > 0 {
570        reporter.finish()?;
571        print_summary(&prepared.summary);
572        return Err("Translation failed; no files were written".to_string());
573    }
574
575    if let Err(err) = apply_translation_results(&mut prepared, &results) {
576        reporter.emit(DashboardEvent::Log {
577            tone: DashboardLogTone::Error,
578            message: err.clone(),
579        });
580        reporter.finish()?;
581        print_summary(&prepared.summary);
582        return Err(err);
583    }
584    reporter.emit(DashboardEvent::Log {
585        tone: DashboardLogTone::Info,
586        message: "Applying translated values".to_string(),
587    });
588    if let Err(err) = validate_translated_output(&prepared) {
589        reporter.emit(DashboardEvent::Log {
590            tone: DashboardLogTone::Error,
591            message: err.clone(),
592        });
593        reporter.finish()?;
594        print_summary(&prepared.summary);
595        return Err(err);
596    }
597    reporter.emit(DashboardEvent::Log {
598        tone: DashboardLogTone::Success,
599        message: "Placeholder validation passed".to_string(),
600    });
601
602    if prepared.opts.dry_run {
603        reporter.emit(DashboardEvent::Log {
604            tone: DashboardLogTone::Info,
605            message: "Dry-run mode: no files were written".to_string(),
606        });
607        reporter.finish()?;
608        print_summary(&prepared.summary);
609        println!("Dry-run mode: no files were written");
610    } else {
611        reporter.emit(DashboardEvent::Log {
612            tone: DashboardLogTone::Info,
613            message: format!("Writing {}", prepared.output_path),
614        });
615        if let Err(err) = write_back(
616            &prepared.target_codec,
617            &prepared.output_path,
618            &prepared.output_format,
619            single_output_language(&prepared.opts.target_langs),
620        ) {
621            reporter.emit(DashboardEvent::Log {
622                tone: DashboardLogTone::Error,
623                message: err.clone(),
624            });
625            reporter.finish()?;
626            print_summary(&prepared.summary);
627            return Err(err);
628        }
629        reporter.emit(DashboardEvent::Log {
630            tone: DashboardLogTone::Success,
631            message: format!("Wrote {}", prepared.output_path),
632        });
633        if prepared.summary.translated > 0
634            && let Some(context) = prepared.tolgee_context.as_ref()
635        {
636            reporter.emit(DashboardEvent::Log {
637                tone: DashboardLogTone::Info,
638                message: format!("Pushing namespace '{}' back to Tolgee", context.namespace()),
639            });
640            if let Err(err) = push_translate_results_to_tolgee(context, false) {
641                reporter.emit(DashboardEvent::Log {
642                    tone: DashboardLogTone::Error,
643                    message: err.clone(),
644                });
645                reporter.finish()?;
646                print_summary(&prepared.summary);
647                return Err(err);
648            }
649            reporter.emit(DashboardEvent::Log {
650                tone: DashboardLogTone::Success,
651                message: "Tolgee sync complete".to_string(),
652            });
653        }
654        reporter.finish()?;
655        print_summary(&prepared.summary);
656        println!("✅ Translate complete: {}", prepared.output_path);
657    }
658    print_translation_results(&prepared, &results);
659
660    Ok(TranslateOutcome {
661        translated: prepared.summary.translated,
662        skipped: count_skipped(&prepared.summary),
663        failed: 0,
664        output_path: Some(prepared.output_path),
665    })
666}
667
668fn prepare_translation(opts: &TranslateOptions) -> Result<PreparedTranslation, String> {
669    let config = load_config(opts.config.as_deref())?;
670    let mut resolved = resolve_options(opts, config.as_ref())?;
671
672    validate_path_inputs(&resolved)?;
673
674    let source_path = resolved.source.clone();
675    let target_path = resolved
676        .target
677        .clone()
678        .unwrap_or_else(|| resolved.source.clone());
679    let output_path = resolved
680        .output
681        .clone()
682        .unwrap_or_else(|| target_path.clone());
683
684    let output_format = infer_format_from_extension(&output_path)
685        .ok_or_else(|| format!("Cannot infer output format from path: {}", output_path))?;
686    let output_lang_hint = infer_language_from_path(&output_path, &output_format)
687        .ok()
688        .flatten();
689
690    if !is_multi_language_format(&output_format) && resolved.target_langs.len() > 1 {
691        return Err(
692            "Multiple --target-lang values are only supported for multi-language output formats"
693                .to_string(),
694        );
695    }
696
697    if opts.target.is_none()
698        && output_path == source_path
699        && !is_multi_language_format(&output_format)
700    {
701        return Err(
702            "Omitting --target is only supported for in-place multi-language files; use --target or --output for single-language formats"
703                .to_string(),
704        );
705    }
706
707    let source_codec = read_codec(&source_path, resolved.source_lang.clone(), resolved.strict)?;
708    let source_resource = select_source_resource(&source_codec, &resolved.source_lang)?;
709
710    let mut target_codec = if Path::new(&target_path).exists() {
711        read_codec(&target_path, output_lang_hint.clone(), resolved.strict)?
712    } else {
713        Codec::new()
714    };
715
716    if !Path::new(&target_path).exists() && is_multi_language_format(&output_format) {
717        ensure_resource_exists(
718            &mut target_codec,
719            &source_resource.resource,
720            &source_resource.language,
721            true,
722        );
723    }
724
725    let target_languages = resolve_target_languages(
726        &target_codec,
727        &resolved.target_langs,
728        output_lang_hint.as_deref(),
729    )?;
730    if let Some(target_language) = target_languages
731        .iter()
732        .find(|language| lang_matches(&source_resource.language, language))
733    {
734        return Err(format!(
735            "Source language '{}' and target language '{}' must differ",
736            source_resource.language, target_language
737        ));
738    }
739    resolved.target_langs = target_languages;
740
741    for target_lang in &resolved.target_langs {
742        ensure_target_resource(&mut target_codec, target_lang)?;
743    }
744    propagate_xcstrings_metadata(&mut target_codec, &source_resource.resource);
745
746    let tolgee_context = prefill_translate_from_tolgee(
747        &TranslateTolgeeSettings {
748            enabled: resolved.use_tolgee,
749            config: resolved.tolgee_config.clone(),
750            namespaces: resolved.tolgee_namespaces.clone(),
751        },
752        &output_path,
753        &mut target_codec,
754        &resolved.target_langs,
755        resolved.strict,
756    )?;
757
758    let (jobs, summary) = build_jobs(
759        &source_resource.resource,
760        &target_codec,
761        &resolved.target_langs,
762        &resolved.statuses,
763        target_supports_explicit_status(&target_path),
764    )?;
765
766    Ok(PreparedTranslation {
767        opts: resolved,
768        source_path,
769        target_path,
770        output_path,
771        output_format,
772        config_path: config.map(|cfg| cfg.path),
773        source_resource,
774        target_codec,
775        tolgee_context,
776        jobs,
777        summary,
778    })
779}
780
781fn print_preamble(prepared: &PreparedTranslation) {
782    println!(
783        "Translating {} -> {} using {}",
784        prepared.source_resource.language,
785        prepared.opts.target_langs.join(", "),
786        translate_engine_label(&prepared.opts)
787    );
788    println!("Source: {}", prepared.source_path);
789    println!("Target: {}", prepared.target_path);
790    if let Some(config_path) = &prepared.config_path {
791        println!("Config: {}", config_path.display());
792    }
793    if prepared.opts.dry_run {
794        println!("Mode: dry-run");
795    }
796}
797
798fn create_translate_reporter(
799    prepared: &PreparedTranslation,
800) -> Result<Box<dyn RunReporter>, String> {
801    let init = DashboardInit {
802        kind: DashboardKind::Translate,
803        title: format!(
804            "{} -> {}",
805            prepared.source_resource.language,
806            prepared.opts.target_langs.join(", ")
807        ),
808        metadata: translate_metadata_rows(prepared),
809        summary_rows: translation_summary_rows(&prepared.summary),
810        items: prepared.jobs.iter().map(translate_dashboard_item).collect(),
811    };
812    match prepared.opts.ui_mode {
813        ResolvedUiMode::Plain => Ok(Box::new(PlainReporter::new(init))),
814        ResolvedUiMode::Tui => Ok(Box::new(TuiReporter::new(init)?)),
815    }
816}
817
818fn translate_metadata_rows(prepared: &PreparedTranslation) -> Vec<SummaryRow> {
819    let mut rows = vec![
820        SummaryRow::new("Provider", translate_engine_label(&prepared.opts)),
821        SummaryRow::new("Source", prepared.source_path.clone()),
822        SummaryRow::new("Target", prepared.target_path.clone()),
823        SummaryRow::new("Output", prepared.output_path.clone()),
824        SummaryRow::new("Concurrency", prepared.opts.concurrency.to_string()),
825    ];
826    if prepared.opts.dry_run {
827        rows.push(SummaryRow::new("Mode", "dry-run"));
828    }
829    if let Some(config_path) = &prepared.config_path {
830        rows.push(SummaryRow::new("Config", config_path.display().to_string()));
831    }
832    rows
833}
834
835fn translate_dashboard_item(job: &TranslationJob) -> DashboardItem {
836    let mut item = DashboardItem::new(
837        translation_job_id(job),
838        job.key.clone(),
839        job.target_lang.clone(),
840        DashboardItemStatus::Queued,
841    );
842    item.source_text = Some(job.source_value.clone());
843    item.note_text = job
844        .existing_comment
845        .clone()
846        .or_else(|| job.source_comment.clone());
847    item
848}
849
850fn translation_job_id(job: &TranslationJob) -> String {
851    format!("{}:{}", job.target_lang, job.key)
852}
853
854fn translation_summary_rows(summary: &TranslationSummary) -> Vec<SummaryRow> {
855    vec![
856        SummaryRow::new("Total candidates", summary.total_entries.to_string()),
857        SummaryRow::new("Queued", summary.queued.to_string()),
858        SummaryRow::new("Translated", summary.translated.to_string()),
859        SummaryRow::new("Skipped total", count_skipped(summary).to_string()),
860        SummaryRow::new("Skipped plural", summary.skipped_plural.to_string()),
861        SummaryRow::new(
862            "Skipped do_not_translate",
863            summary.skipped_do_not_translate.to_string(),
864        ),
865        SummaryRow::new("Skipped status", summary.skipped_status.to_string()),
866        SummaryRow::new(
867            "Skipped empty source",
868            summary.skipped_empty_source.to_string(),
869        ),
870        SummaryRow::new("Failed", summary.failed.to_string()),
871    ]
872}
873
874fn print_summary(summary: &TranslationSummary) {
875    println!("Total candidate translations: {}", summary.total_entries);
876    println!("Queued for translation: {}", summary.queued);
877    println!("Translated: {}", summary.translated);
878    println!("Skipped (plural): {}", summary.skipped_plural);
879    println!(
880        "Skipped (do_not_translate): {}",
881        summary.skipped_do_not_translate
882    );
883    println!("Skipped (status): {}", summary.skipped_status);
884    println!("Skipped (empty source): {}", summary.skipped_empty_source);
885    println!("Failed: {}", summary.failed);
886}
887
888fn count_skipped(summary: &TranslationSummary) -> usize {
889    summary.skipped_plural
890        + summary.skipped_do_not_translate
891        + summary.skipped_status
892        + summary.skipped_empty_source
893}
894
895fn print_translation_results(
896    prepared: &PreparedTranslation,
897    results: &HashMap<(String, String), String>,
898) {
899    if results.is_empty() {
900        return;
901    }
902
903    println!("Translation results:");
904    for job in &prepared.jobs {
905        if let Some(translated_value) = results.get(&(job.key.clone(), job.target_lang.clone())) {
906            println!(
907                "{}\t{}\t{} => {}",
908                job.target_lang,
909                job.key,
910                format_inline_value(&job.source_value),
911                format_inline_value(translated_value)
912            );
913        }
914    }
915}
916
917fn format_inline_value(value: &str) -> String {
918    value
919        .replace('\\', "\\\\")
920        .replace('\n', "\\n")
921        .replace('\r', "\\r")
922        .replace('\t', "\\t")
923}
924
925fn apply_translation_results(
926    prepared: &mut PreparedTranslation,
927    results: &HashMap<(String, String), String>,
928) -> Result<(), String> {
929    for job in &prepared.jobs {
930        let Some(translated_value) = results.get(&(job.key.clone(), job.target_lang.clone()))
931        else {
932            continue;
933        };
934
935        if let Some(existing) = prepared
936            .target_codec
937            .find_entry_mut(&job.key, &job.target_lang)
938        {
939            existing.value = Translation::Singular(translated_value.clone());
940            existing.status = prepared.opts.output_status.clone();
941        } else {
942            prepared
943                .target_codec
944                .add_entry(
945                    &job.key,
946                    &job.target_lang,
947                    Translation::Singular(translated_value.clone()),
948                    job.existing_comment
949                        .clone()
950                        .or_else(|| job.source_comment.clone()),
951                    Some(prepared.opts.output_status.clone()),
952                )
953                .map_err(|e| e.to_string())?;
954        }
955    }
956    Ok(())
957}
958
959fn validate_translated_output(prepared: &PreparedTranslation) -> Result<(), String> {
960    let mut validation_codec = prepared.target_codec.clone();
961    ensure_resource_exists(
962        &mut validation_codec,
963        &prepared.source_resource.resource,
964        &prepared.source_resource.language,
965        false,
966    );
967    validation_codec
968        .validate_placeholders(prepared.opts.strict)
969        .map_err(|e| format!("Placeholder validation failed after translation: {}", e))
970}
971
972fn validate_translation_preflight(prepared: &PreparedTranslation) -> Result<(), String> {
973    validate_output_serialization(
974        &prepared.target_codec,
975        &prepared.output_format,
976        &prepared.output_path,
977        single_output_language(&prepared.opts.target_langs),
978    )
979    .map_err(|e| format!("Preflight output validation failed: {}", e))
980}
981
982fn validate_output_serialization(
983    codec: &Codec,
984    output_format: &FormatType,
985    output_path: &str,
986    target_lang: Option<&str>,
987) -> Result<(), String> {
988    match output_format {
989        FormatType::Strings(_) => {
990            let target_lang = target_lang.ok_or_else(|| {
991                "Single-language outputs require exactly one target language".to_string()
992            })?;
993            let resource = codec
994                .resources
995                .iter()
996                .find(|item| lang_matches(&item.metadata.language, target_lang))
997                .ok_or_else(|| format!("Target language '{}' not found in output", target_lang))?;
998            let format = StringsFormat::try_from(resource.clone())
999                .map_err(|e| format!("Error building Strings output: {}", e))?;
1000            let mut out = Vec::new();
1001            format
1002                .to_writer(&mut out)
1003                .map_err(|e| format!("Error serializing Strings output: {}", e))
1004        }
1005        FormatType::AndroidStrings(_) => {
1006            let target_lang = target_lang.ok_or_else(|| {
1007                "Single-language outputs require exactly one target language".to_string()
1008            })?;
1009            let resource = codec
1010                .resources
1011                .iter()
1012                .find(|item| lang_matches(&item.metadata.language, target_lang))
1013                .ok_or_else(|| format!("Target language '{}' not found in output", target_lang))?;
1014            let format = AndroidStringsFormat::from(resource.clone());
1015            let mut out = Vec::new();
1016            format
1017                .to_writer(&mut out)
1018                .map_err(|e| format!("Error serializing Android output: {}", e))
1019        }
1020        FormatType::Xcstrings => {
1021            let format = XcstringsFormat::try_from(codec.resources.clone())
1022                .map_err(|e| format!("Error building Xcstrings output: {}", e))?;
1023            let mut out = Vec::new();
1024            format
1025                .to_writer(&mut out)
1026                .map_err(|e| format!("Error serializing Xcstrings output: {}", e))
1027        }
1028        FormatType::CSV => {
1029            let format = CSVFormat::try_from(codec.resources.clone())
1030                .map_err(|e| format!("Error building CSV output: {}", e))?;
1031            let mut out = Vec::new();
1032            format
1033                .to_writer(&mut out)
1034                .map_err(|e| format!("Error serializing CSV output: {}", e))
1035        }
1036        FormatType::TSV => {
1037            let format = TSVFormat::try_from(codec.resources.clone())
1038                .map_err(|e| format!("Error building TSV output: {}", e))?;
1039            let mut out = Vec::new();
1040            format
1041                .to_writer(&mut out)
1042                .map_err(|e| format!("Error serializing TSV output: {}", e))
1043        }
1044    }
1045    .map_err(|err| format!("{} ({})", err, output_path))
1046}
1047
1048fn build_jobs(
1049    source: &Resource,
1050    target_codec: &Codec,
1051    target_langs: &[String],
1052    statuses: &[EntryStatus],
1053    explicit_target_status: bool,
1054) -> Result<(Vec<TranslationJob>, TranslationSummary), String> {
1055    let mut jobs = Vec::new();
1056    let mut summary = TranslationSummary {
1057        total_entries: source.entries.len() * target_langs.len(),
1058        ..TranslationSummary::default()
1059    };
1060
1061    for target_lang in target_langs {
1062        for entry in &source.entries {
1063            if entry.status == EntryStatus::DoNotTranslate {
1064                summary.skipped_do_not_translate += 1;
1065                continue;
1066            }
1067
1068            let source_text = match &entry.value {
1069                Translation::Plural(_) => {
1070                    summary.skipped_plural += 1;
1071                    continue;
1072                }
1073                Translation::Empty => {
1074                    summary.skipped_empty_source += 1;
1075                    continue;
1076                }
1077                Translation::Singular(text) if text.trim().is_empty() => {
1078                    summary.skipped_empty_source += 1;
1079                    continue;
1080                }
1081                Translation::Singular(text) => text,
1082            };
1083
1084            let target_entry = target_codec.find_entry(&entry.id, target_lang);
1085
1086            if target_entry.is_some_and(|item| item.status == EntryStatus::DoNotTranslate) {
1087                summary.skipped_do_not_translate += 1;
1088                continue;
1089            }
1090
1091            let effective_status = target_entry
1092                .map(|item| effective_target_status(item, explicit_target_status))
1093                .unwrap_or(EntryStatus::New);
1094
1095            if !statuses.contains(&effective_status) {
1096                summary.skipped_status += 1;
1097                continue;
1098            }
1099
1100            jobs.push(TranslationJob {
1101                key: entry.id.clone(),
1102                source_lang: source.metadata.language.clone(),
1103                target_lang: target_lang.clone(),
1104                source_value: source_text.clone(),
1105                source_comment: entry.comment.clone(),
1106                existing_comment: target_entry.and_then(|item| item.comment.clone()),
1107            });
1108            summary.queued += 1;
1109        }
1110    }
1111
1112    Ok((jobs, summary))
1113}
1114
1115fn effective_target_status(entry: &Entry, explicit_target_status: bool) -> EntryStatus {
1116    if explicit_target_status {
1117        return entry.status.clone();
1118    }
1119
1120    match &entry.value {
1121        Translation::Empty => EntryStatus::New,
1122        Translation::Singular(text) if text.trim().is_empty() => EntryStatus::New,
1123        _ => EntryStatus::Translated,
1124    }
1125}
1126
1127fn ensure_target_resource(codec: &mut Codec, language: &str) -> Result<(), String> {
1128    if codec.get_by_language(language).is_none() {
1129        codec.add_resource(Resource {
1130            metadata: Metadata {
1131                language: language.to_string(),
1132                domain: String::new(),
1133                custom: HashMap::new(),
1134            },
1135            entries: Vec::new(),
1136        });
1137    }
1138    Ok(())
1139}
1140
1141fn ensure_resource_exists(
1142    codec: &mut Codec,
1143    resource: &Resource,
1144    language: &str,
1145    clone_entries: bool,
1146) {
1147    if codec.get_by_language(language).is_some() {
1148        return;
1149    }
1150
1151    codec.add_resource(Resource {
1152        metadata: resource.metadata.clone(),
1153        entries: if clone_entries {
1154            resource.entries.clone()
1155        } else {
1156            Vec::new()
1157        },
1158    });
1159}
1160
1161fn propagate_xcstrings_metadata(codec: &mut Codec, source_resource: &Resource) {
1162    let source_language = source_resource
1163        .metadata
1164        .custom
1165        .get("source_language")
1166        .cloned()
1167        .unwrap_or_else(|| source_resource.metadata.language.clone());
1168    let version = source_resource
1169        .metadata
1170        .custom
1171        .get("version")
1172        .cloned()
1173        .unwrap_or_else(|| "1.0".to_string());
1174
1175    for resource in &mut codec.resources {
1176        resource
1177            .metadata
1178            .custom
1179            .entry("source_language".to_string())
1180            .or_insert_with(|| source_language.to_string());
1181        resource
1182            .metadata
1183            .custom
1184            .entry("version".to_string())
1185            .or_insert_with(|| version.clone());
1186    }
1187}
1188
1189fn validate_path_inputs(opts: &ResolvedOptions) -> Result<(), String> {
1190    if !Path::new(&opts.source).is_file() {
1191        return Err(format!("Source file does not exist: {}", opts.source));
1192    }
1193
1194    if let Some(target) = &opts.target {
1195        if Path::new(target).exists() && !Path::new(target).is_file() {
1196            return Err(format!("Target path is not a file: {}", target));
1197        }
1198        validate_output_path(target)?;
1199    }
1200
1201    if let Some(output) = &opts.output {
1202        validate_output_path(output)?;
1203    }
1204
1205    Ok(())
1206}
1207
1208fn resolve_options(
1209    opts: &TranslateOptions,
1210    config: Option<&LoadedConfig>,
1211) -> Result<ResolvedOptions, String> {
1212    let cfg = config.map(|item| &item.data.translate);
1213    let tolgee_cfg = config.map(|item| &item.data.tolgee);
1214    let config_dir = config.and_then(LoadedConfig::config_dir);
1215    let source_lang = opts
1216        .source_lang
1217        .clone()
1218        .or_else(|| cfg.and_then(|item| item.resolved_source_lang().map(str::to_string)));
1219    let target_langs = if !opts.target_langs.is_empty() {
1220        parse_language_list(opts.target_langs.iter().map(String::as_str))?
1221    } else {
1222        parse_language_list(
1223            cfg.and_then(|item| item.resolved_target_langs())
1224                .into_iter()
1225                .flatten()
1226                .flat_map(|value| value.split(',')),
1227        )?
1228    };
1229    if target_langs.is_empty() {
1230        return Err(
1231            "--target-lang is required (or set translate.output.lang/translate.target_lang in langcodec.toml)"
1232                .to_string(),
1233        );
1234    }
1235    if let Some(lang) = &source_lang {
1236        validate_language_code(lang)?;
1237    }
1238
1239    let use_tolgee = opts.use_tolgee
1240        || opts.tolgee_config.is_some()
1241        || !opts.tolgee_namespaces.is_empty()
1242        || cfg.and_then(|item| item.use_tolgee).unwrap_or(false);
1243
1244    let tolgee_config = opts.tolgee_config.clone().or_else(|| {
1245        tolgee_cfg
1246            .and_then(|item| item.config.as_deref())
1247            .map(|path| resolve_config_relative_path(config_dir, path))
1248    });
1249    let tolgee_namespaces = if !opts.tolgee_namespaces.is_empty() {
1250        opts.tolgee_namespaces.clone()
1251    } else {
1252        tolgee_cfg
1253            .and_then(|item| item.namespaces.clone())
1254            .unwrap_or_default()
1255    };
1256
1257    let provider_resolution = resolve_provider(
1258        opts.provider.as_deref(),
1259        config.map(|item| &item.data),
1260        cfg.and_then(|item| item.provider.as_deref()),
1261    );
1262    let (provider, provider_error) = match provider_resolution {
1263        Ok(provider) => (Some(provider), None),
1264        Err(err) if use_tolgee => (None, Some(err)),
1265        Err(err) => return Err(err),
1266    };
1267    let (model, model_error) = if let Some(provider) = provider.as_ref() {
1268        match resolve_model(
1269            opts.model.as_deref(),
1270            config.map(|item| &item.data),
1271            provider,
1272            cfg.and_then(|item| item.model.as_deref()),
1273        ) {
1274            Ok(model) => (Some(model), None),
1275            Err(err) if use_tolgee => (None, Some(err)),
1276            Err(err) => return Err(err),
1277        }
1278    } else {
1279        (None, None)
1280    };
1281
1282    let concurrency = opts
1283        .concurrency
1284        .or_else(|| cfg.and_then(|item| item.concurrency))
1285        .unwrap_or(DEFAULT_CONCURRENCY);
1286    if concurrency == 0 {
1287        return Err("Concurrency must be greater than zero".to_string());
1288    }
1289
1290    let statuses = parse_status_filter(
1291        opts.status.as_deref(),
1292        cfg.and_then(|item| item.resolved_filter_status()),
1293    )?;
1294    let output_status = parse_output_status(cfg.and_then(|item| item.resolved_output_status()))?;
1295    let ui_mode = resolve_ui_mode_for_current_terminal(opts.ui_mode)?;
1296
1297    Ok(ResolvedOptions {
1298        source: opts
1299            .source
1300            .clone()
1301            .ok_or_else(|| "--source is required".to_string())?,
1302        target: opts.target.clone(),
1303        output: opts.output.clone(),
1304        source_lang,
1305        target_langs,
1306        statuses,
1307        output_status,
1308        provider,
1309        model,
1310        provider_error,
1311        model_error,
1312        concurrency,
1313        use_tolgee,
1314        tolgee_config,
1315        tolgee_namespaces,
1316        dry_run: opts.dry_run,
1317        strict: opts.strict,
1318        ui_mode,
1319    })
1320}
1321
1322fn parse_status_filter(
1323    cli: Option<&str>,
1324    cfg: Option<&Vec<String>>,
1325) -> Result<Vec<EntryStatus>, String> {
1326    let raw_values: Vec<String> = if let Some(cli) = cli {
1327        cli.split(',')
1328            .map(str::trim)
1329            .filter(|value| !value.is_empty())
1330            .map(ToOwned::to_owned)
1331            .collect()
1332    } else if let Some(cfg) = cfg {
1333        cfg.clone()
1334    } else {
1335        DEFAULT_STATUSES
1336            .iter()
1337            .map(|value| value.to_string())
1338            .collect()
1339    };
1340
1341    let mut statuses = Vec::new();
1342    for raw in raw_values {
1343        let normalized = raw.replace(['-', ' '], "_");
1344        let parsed = normalized
1345            .parse::<EntryStatus>()
1346            .map_err(|e| format!("Invalid translate status '{}': {}", raw, e))?;
1347        if !statuses.contains(&parsed) {
1348            statuses.push(parsed);
1349        }
1350    }
1351    Ok(statuses)
1352}
1353
1354fn parse_output_status(raw: Option<&str>) -> Result<EntryStatus, String> {
1355    let Some(raw) = raw else {
1356        return Ok(EntryStatus::NeedsReview);
1357    };
1358
1359    let normalized = raw.trim().replace(['-', ' '], "_");
1360    let parsed = normalized
1361        .parse::<EntryStatus>()
1362        .map_err(|e| format!("Invalid translate output_status '{}': {}", raw, e))?;
1363
1364    match parsed {
1365        EntryStatus::NeedsReview | EntryStatus::Translated => Ok(parsed),
1366        _ => Err(format!(
1367            "translate output status must be either 'needs_review' or 'translated', got '{}'",
1368            raw
1369        )),
1370    }
1371}
1372
1373fn parse_language_list<'a, I>(values: I) -> Result<Vec<String>, String>
1374where
1375    I: IntoIterator<Item = &'a str>,
1376{
1377    let mut parsed: Vec<String> = Vec::new();
1378    for raw in values {
1379        let value = raw.trim();
1380        if value.is_empty() {
1381            continue;
1382        }
1383        validate_language_code(value)?;
1384        if !parsed
1385            .iter()
1386            .any(|existing| normalize_lang(existing) == normalize_lang(value))
1387        {
1388            parsed.push(value.to_string());
1389        }
1390    }
1391    Ok(parsed)
1392}
1393
1394fn read_codec(path: &str, language_hint: Option<String>, strict: bool) -> Result<Codec, String> {
1395    let mut codec = Codec::new();
1396    codec
1397        .read_file_by_extension_with_options(
1398            path,
1399            &ReadOptions::new()
1400                .with_language_hint(language_hint)
1401                .with_strict(strict),
1402        )
1403        .map_err(|e| format!("Failed to read '{}': {}", path, e))?;
1404    Ok(codec)
1405}
1406
1407fn select_source_resource(
1408    codec: &Codec,
1409    requested_lang: &Option<String>,
1410) -> Result<SelectedResource, String> {
1411    if let Some(lang) = requested_lang {
1412        if let Some(resource) = codec
1413            .resources
1414            .iter()
1415            .find(|item| lang_matches(&item.metadata.language, lang))
1416            .cloned()
1417        {
1418            return Ok(SelectedResource {
1419                language: resource.metadata.language.clone(),
1420                resource,
1421            });
1422        }
1423
1424        return Err(format!("Source language '{}' not found", lang));
1425    }
1426
1427    if codec.resources.len() == 1 {
1428        let resource = codec.resources[0].clone();
1429        return Ok(SelectedResource {
1430            language: resource.metadata.language.clone(),
1431            resource,
1432        });
1433    }
1434
1435    Err("Multiple source languages present; specify --source-lang".to_string())
1436}
1437
1438fn resolve_target_languages(
1439    codec: &Codec,
1440    requested_langs: &[String],
1441    inferred_from_output: Option<&str>,
1442) -> Result<Vec<String>, String> {
1443    let mut resolved: Vec<String> = Vec::new();
1444
1445    for requested_lang in requested_langs {
1446        let canonical = if let Some(resource) = codec
1447            .resources
1448            .iter()
1449            .find(|item| lang_matches(&item.metadata.language, requested_lang))
1450        {
1451            resource.metadata.language.clone()
1452        } else if let Some(inferred) = inferred_from_output
1453            && lang_matches(inferred, requested_lang)
1454        {
1455            inferred.to_string()
1456        } else {
1457            requested_lang.to_string()
1458        };
1459
1460        if !resolved
1461            .iter()
1462            .any(|existing| normalize_lang(existing) == normalize_lang(&canonical))
1463        {
1464            resolved.push(canonical);
1465        }
1466    }
1467
1468    Ok(resolved)
1469}
1470
1471fn lang_matches(resource_lang: &str, requested_lang: &str) -> bool {
1472    normalize_lang(resource_lang) == normalize_lang(requested_lang)
1473        || normalize_lang(resource_lang)
1474            .split('-')
1475            .next()
1476            .unwrap_or(resource_lang)
1477            == normalize_lang(requested_lang)
1478                .split('-')
1479                .next()
1480                .unwrap_or(requested_lang)
1481}
1482
1483fn normalize_lang(lang: &str) -> String {
1484    lang.trim().replace('_', "-").to_ascii_lowercase()
1485}
1486
1487fn is_multi_language_format(format: &FormatType) -> bool {
1488    matches!(
1489        format,
1490        FormatType::Xcstrings | FormatType::CSV | FormatType::TSV
1491    )
1492}
1493
1494fn target_supports_explicit_status(path: &str) -> bool {
1495    Path::new(path)
1496        .extension()
1497        .and_then(|ext| ext.to_str())
1498        .is_some_and(|ext| ext.eq_ignore_ascii_case("xcstrings"))
1499}
1500
1501fn single_output_language(target_langs: &[String]) -> Option<&str> {
1502    if target_langs.len() == 1 {
1503        Some(target_langs[0].as_str())
1504    } else {
1505        None
1506    }
1507}
1508
1509fn write_back(
1510    codec: &Codec,
1511    output_path: &str,
1512    output_format: &FormatType,
1513    target_lang: Option<&str>,
1514) -> Result<(), String> {
1515    match output_format {
1516        FormatType::Strings(_) | FormatType::AndroidStrings(_) => {
1517            let target_lang = target_lang.ok_or_else(|| {
1518                "Single-language outputs require exactly one target language".to_string()
1519            })?;
1520            let resource = codec
1521                .resources
1522                .iter()
1523                .find(|item| lang_matches(&item.metadata.language, target_lang))
1524                .ok_or_else(|| format!("Target language '{}' not found in output", target_lang))?;
1525            Codec::write_resource_to_file(resource, output_path)
1526                .map_err(|e| format!("Error writing output: {}", e))
1527        }
1528        FormatType::Xcstrings | FormatType::CSV | FormatType::TSV => {
1529            convert_resources_to_format(codec.resources.clone(), output_path, output_format.clone())
1530                .map_err(|e| format!("Error writing output: {}", e))
1531        }
1532    }
1533}
1534
1535fn create_mentra_backend(opts: &ResolvedOptions) -> Result<MentraBackend, String> {
1536    let provider = opts.provider.as_ref().ok_or_else(|| {
1537        opts.provider_error.clone().unwrap_or_else(|| {
1538            "--provider is required when Tolgee prefill does not satisfy all translations"
1539                .to_string()
1540        })
1541    })?;
1542    let model = opts.model.as_ref().ok_or_else(|| {
1543        opts.model_error.clone().unwrap_or_else(|| {
1544            "--model is required when Tolgee prefill does not satisfy all translations".to_string()
1545        })
1546    })?;
1547    let setup = build_provider(provider)?;
1548    if setup.provider_kind != *provider {
1549        return Err("Resolved provider mismatch".to_string());
1550    }
1551    Ok(MentraBackend {
1552        provider: setup.provider,
1553        model: model.clone(),
1554    })
1555}
1556
1557fn translate_engine_label(opts: &ResolvedOptions) -> String {
1558    let ai_label = opts
1559        .provider
1560        .as_ref()
1561        .zip(opts.model.as_ref())
1562        .map(|(provider, model)| format!("{}:{}", provider.display_name(), model));
1563
1564    match (opts.use_tolgee, ai_label) {
1565        (true, Some(ai_label)) => format!("tolgee + {}", ai_label),
1566        (true, None) => "tolgee".to_string(),
1567        (false, Some(ai_label)) => ai_label,
1568        (false, None) => "unconfigured".to_string(),
1569    }
1570}
1571
1572fn build_prompt(request: &BackendRequest) -> String {
1573    let mut prompt = format!(
1574        "Translate the following localization value from {} to {}.\nKey: {}\nSource value:\n{}\n",
1575        request.source_lang, request.target_lang, request.key, request.source_value
1576    );
1577    if let Some(comment) = &request.source_comment {
1578        prompt.push_str("\nComment:\n");
1579        prompt.push_str(comment);
1580        prompt.push('\n');
1581    }
1582    prompt.push_str(
1583        "\nReturn JSON only in this exact shape: {\"translation\":\"...\"}. Do not wrap in markdown fences unless necessary.",
1584    );
1585    prompt
1586}
1587
1588fn collect_text_blocks(response: &provider::Response) -> String {
1589    response
1590        .content
1591        .iter()
1592        .filter_map(|block| match block {
1593            ContentBlock::Text { text } => Some(text.as_str()),
1594            _ => None,
1595        })
1596        .collect::<Vec<_>>()
1597        .join("")
1598}
1599
1600fn parse_translation_response(text: &str) -> Result<String, String> {
1601    let trimmed = text.trim();
1602    if trimmed.is_empty() {
1603        return Err("Model returned an empty translation".to_string());
1604    }
1605
1606    if let Ok(payload) = serde_json::from_str::<ModelTranslationPayload>(trimmed) {
1607        return Ok(payload.translation);
1608    }
1609
1610    if let Some(json_body) = extract_json_body(trimmed)
1611        && let Ok(payload) = serde_json::from_str::<ModelTranslationPayload>(&json_body)
1612    {
1613        return Ok(payload.translation);
1614    }
1615
1616    Err(format!(
1617        "Model response was not valid translation JSON: {}",
1618        trimmed
1619    ))
1620}
1621
1622fn extract_json_body(text: &str) -> Option<String> {
1623    let fenced = text
1624        .strip_prefix("```json")
1625        .or_else(|| text.strip_prefix("```"))
1626        .map(str::trim_start)?;
1627    let unfenced = fenced.strip_suffix("```")?.trim();
1628    Some(unfenced.to_string())
1629}
1630
1631fn format_provider_error(err: ProviderError) -> String {
1632    format!("Provider request failed: {}", err)
1633}
1634
1635#[cfg(test)]
1636mod tests {
1637    use super::*;
1638    use std::{collections::VecDeque, fs, path::PathBuf, sync::Mutex};
1639    use tempfile::TempDir;
1640
1641    type MockResponseKey = (String, String);
1642    type MockResponse = Result<String, String>;
1643    type MockResponseQueue = VecDeque<MockResponse>;
1644    type MockResponseMap = HashMap<MockResponseKey, MockResponseQueue>;
1645    type MockResponseSeed = ((&'static str, &'static str), MockResponse);
1646
1647    #[derive(Clone)]
1648    struct MockBackend {
1649        responses: Arc<Mutex<MockResponseMap>>,
1650    }
1651
1652    impl MockBackend {
1653        fn new(responses: Vec<MockResponseSeed>) -> Self {
1654            let mut mapped = HashMap::new();
1655            for ((key, target_lang), value) in responses {
1656                mapped
1657                    .entry((key.to_string(), target_lang.to_string()))
1658                    .or_insert_with(VecDeque::new)
1659                    .push_back(value);
1660            }
1661            Self {
1662                responses: Arc::new(Mutex::new(mapped)),
1663            }
1664        }
1665    }
1666
1667    #[async_trait]
1668    impl TranslationBackend for MockBackend {
1669        async fn translate(&self, request: BackendRequest) -> Result<String, String> {
1670            self.responses
1671                .lock()
1672                .unwrap()
1673                .get_mut(&(request.key.clone(), request.target_lang.clone()))
1674                .and_then(|values| values.pop_front())
1675                .unwrap_or_else(|| Err("missing mock response".to_string()))
1676        }
1677    }
1678
1679    fn base_options(source: &Path, target: Option<&Path>) -> TranslateOptions {
1680        TranslateOptions {
1681            source: Some(source.to_string_lossy().to_string()),
1682            target: target.map(|path| path.to_string_lossy().to_string()),
1683            output: None,
1684            source_lang: Some("en".to_string()),
1685            target_langs: vec!["fr".to_string()],
1686            status: None,
1687            provider: Some("openai".to_string()),
1688            model: Some("gpt-4.1-mini".to_string()),
1689            concurrency: Some(2),
1690            config: None,
1691            use_tolgee: false,
1692            tolgee_config: None,
1693            tolgee_namespaces: Vec::new(),
1694            dry_run: false,
1695            strict: false,
1696            ui_mode: UiMode::Plain,
1697        }
1698    }
1699
1700    #[cfg(unix)]
1701    fn make_executable(path: &Path) {
1702        use std::os::unix::fs::PermissionsExt;
1703
1704        let mut perms = fs::metadata(path).unwrap().permissions();
1705        perms.set_mode(0o755);
1706        fs::set_permissions(path, perms).unwrap();
1707    }
1708
1709    fn write_fake_tolgee(
1710        project_root: &Path,
1711        payload_path: &Path,
1712        capture_path: &Path,
1713        log_path: &Path,
1714    ) {
1715        let bin_dir = project_root.join("node_modules/.bin");
1716        fs::create_dir_all(&bin_dir).unwrap();
1717        let script_path = bin_dir.join("tolgee");
1718        let script = format!(
1719            r#"#!/bin/sh
1720config=""
1721subcommand=""
1722while [ "$#" -gt 0 ]; do
1723  case "$1" in
1724    --config)
1725      config="$2"
1726      shift 2
1727      ;;
1728    pull|push)
1729      subcommand="$1"
1730      shift
1731      ;;
1732    *)
1733      shift
1734      ;;
1735  esac
1736done
1737
1738echo "$subcommand|$config" >> "{log_path}"
1739cp "$config" "{capture_path}"
1740
1741if [ "$subcommand" = "push" ]; then
1742  exit 0
1743fi
1744
1745eval "$(
1746python3 - "$config" <<'PY'
1747import json
1748import shlex
1749import sys
1750
1751with open(sys.argv[1], "r", encoding="utf-8") as fh:
1752    data = json.load(fh)
1753
1754pull_path = data.get("pull", {{}}).get("path", "")
1755namespaces = data.get("pull", {{}}).get("namespaces") or data.get("push", {{}}).get("namespaces") or []
1756if namespaces:
1757    namespace = namespaces[0]
1758else:
1759    files = data.get("push", {{}}).get("files") or []
1760    namespace = files[0]["namespace"] if files else ""
1761
1762print(f"pull_path={{shlex.quote(pull_path)}}")
1763print(f"namespace={{shlex.quote(namespace)}}")
1764PY
1765)"
1766mkdir -p "$pull_path/$namespace"
1767cp "{payload_path}" "$pull_path/$namespace/Localizable.xcstrings"
1768"#,
1769            payload_path = payload_path.display(),
1770            capture_path = capture_path.display(),
1771            log_path = log_path.display(),
1772        );
1773        fs::write(&script_path, script).unwrap();
1774        #[cfg(unix)]
1775        make_executable(&script_path);
1776    }
1777
1778    fn write_translate_tolgee_config(project_root: &Path) -> PathBuf {
1779        let config_path = project_root.join(".tolgeerc.json");
1780        fs::write(
1781            &config_path,
1782            r#"{
1783  "format": "APPLE_XCSTRINGS",
1784  "push": {
1785    "files": [
1786      {
1787        "path": "Localizable.xcstrings",
1788        "namespace": "Core"
1789      }
1790    ]
1791  },
1792  "pull": {
1793    "path": "./tolgee-temp",
1794    "fileStructureTemplate": "/{namespace}/Localizable.{extension}"
1795  }
1796}"#,
1797        )
1798        .unwrap();
1799        config_path
1800    }
1801
1802    fn write_translate_source_catalog(path: &Path) {
1803        fs::write(
1804            path,
1805            r#"{
1806  "sourceLanguage" : "en",
1807  "version" : "1.0",
1808  "strings" : {
1809    "welcome" : {
1810      "localizations" : {
1811        "en" : {
1812          "stringUnit" : {
1813            "state" : "translated",
1814            "value" : "Welcome"
1815          }
1816        }
1817      }
1818    },
1819    "bye" : {
1820      "localizations" : {
1821        "en" : {
1822          "stringUnit" : {
1823            "state" : "translated",
1824            "value" : "Goodbye"
1825          }
1826        }
1827      }
1828    }
1829  }
1830}"#,
1831        )
1832        .unwrap();
1833    }
1834
1835    fn write_translate_tolgee_payload(path: &Path) {
1836        fs::write(
1837            path,
1838            r#"{
1839  "sourceLanguage" : "en",
1840  "version" : "1.0",
1841  "strings" : {
1842    "welcome" : {
1843      "localizations" : {
1844        "fr" : {
1845          "stringUnit" : {
1846            "state" : "translated",
1847            "value" : "Bienvenue"
1848          }
1849        }
1850      }
1851    }
1852  }
1853}"#,
1854        )
1855        .unwrap();
1856    }
1857
1858    #[test]
1859    fn translates_missing_entries_into_target_file() {
1860        let temp_dir = TempDir::new().unwrap();
1861        let source = temp_dir.path().join("en.strings");
1862        let target = temp_dir.path().join("fr.strings");
1863
1864        fs::write(
1865            &source,
1866            "\"welcome\" = \"Welcome\";\n\"bye\" = \"Goodbye\";\n",
1867        )
1868        .unwrap();
1869
1870        let prepared = prepare_translation(&base_options(&source, Some(&target))).unwrap();
1871        let outcome = run_prepared_translation(
1872            prepared,
1873            Some(Arc::new(MockBackend::new(vec![
1874                (("welcome", "fr"), Ok("Bienvenue".to_string())),
1875                (("bye", "fr"), Ok("Au revoir".to_string())),
1876            ]))),
1877        )
1878        .unwrap();
1879
1880        assert_eq!(outcome.translated, 2);
1881        let written = fs::read_to_string(&target).unwrap();
1882        assert!(written.contains("\"welcome\" = \"Bienvenue\";"));
1883        assert!(written.contains("\"bye\" = \"Au revoir\";"));
1884    }
1885
1886    #[test]
1887    fn translates_strings_source_into_android_target_file() {
1888        let temp_dir = TempDir::new().unwrap();
1889        let source = temp_dir.path().join("en.strings");
1890        let target_dir = temp_dir.path().join("values-fr");
1891        let target = target_dir.join("strings.xml");
1892        fs::create_dir_all(&target_dir).unwrap();
1893        fs::write(
1894            &source,
1895            "\"welcome\" = \"Welcome\";\n\"bye\" = \"Goodbye\";\n",
1896        )
1897        .unwrap();
1898
1899        let prepared = prepare_translation(&base_options(&source, Some(&target))).unwrap();
1900        let outcome = run_prepared_translation(
1901            prepared,
1902            Some(Arc::new(MockBackend::new(vec![
1903                (("welcome", "fr"), Ok("Bienvenue".to_string())),
1904                (("bye", "fr"), Ok("Au revoir".to_string())),
1905            ]))),
1906        )
1907        .unwrap();
1908
1909        assert_eq!(outcome.translated, 2);
1910        let written = fs::read_to_string(&target).unwrap();
1911        assert!(written.contains("<string name=\"welcome\">Bienvenue</string>"));
1912        assert!(written.contains("<string name=\"bye\">Au revoir</string>"));
1913    }
1914
1915    #[test]
1916    fn translates_android_source_into_strings_target_file() {
1917        let temp_dir = TempDir::new().unwrap();
1918        let source_dir = temp_dir.path().join("values");
1919        let source = source_dir.join("strings.xml");
1920        let target = temp_dir.path().join("fr.strings");
1921        fs::create_dir_all(&source_dir).unwrap();
1922        fs::write(
1923            &source,
1924            r#"<resources>
1925<string name="welcome">Welcome</string>
1926<string name="bye">Goodbye</string>
1927</resources>
1928"#,
1929        )
1930        .unwrap();
1931
1932        let prepared = prepare_translation(&base_options(&source, Some(&target))).unwrap();
1933        let outcome = run_prepared_translation(
1934            prepared,
1935            Some(Arc::new(MockBackend::new(vec![
1936                (("welcome", "fr"), Ok("Bienvenue".to_string())),
1937                (("bye", "fr"), Ok("Au revoir".to_string())),
1938            ]))),
1939        )
1940        .unwrap();
1941
1942        assert_eq!(outcome.translated, 2);
1943        let written = fs::read_to_string(&target).unwrap();
1944        assert!(written.contains("\"welcome\" = \"Bienvenue\";"));
1945        assert!(written.contains("\"bye\" = \"Au revoir\";"));
1946    }
1947
1948    #[test]
1949    fn dry_run_does_not_write_target() {
1950        let temp_dir = TempDir::new().unwrap();
1951        let source = temp_dir.path().join("en.strings");
1952        let target = temp_dir.path().join("fr.strings");
1953
1954        fs::write(&source, "\"welcome\" = \"Welcome\";\n").unwrap();
1955        fs::write(&target, "\"welcome\" = \"\";\n").unwrap();
1956
1957        let mut options = base_options(&source, Some(&target));
1958        options.dry_run = true;
1959
1960        let before = fs::read_to_string(&target).unwrap();
1961        let prepared = prepare_translation(&options).unwrap();
1962        let outcome = run_prepared_translation(
1963            prepared,
1964            Some(Arc::new(MockBackend::new(vec![(
1965                ("welcome", "fr"),
1966                Ok("Bienvenue".to_string()),
1967            )]))),
1968        )
1969        .unwrap();
1970        let after = fs::read_to_string(&target).unwrap();
1971
1972        assert_eq!(outcome.translated, 1);
1973        assert_eq!(before, after);
1974    }
1975
1976    #[test]
1977    fn fails_without_writing_when_any_translation_fails() {
1978        let temp_dir = TempDir::new().unwrap();
1979        let source = temp_dir.path().join("en.strings");
1980        let target = temp_dir.path().join("fr.strings");
1981
1982        fs::write(
1983            &source,
1984            "\"welcome\" = \"Welcome\";\n\"bye\" = \"Goodbye\";\n",
1985        )
1986        .unwrap();
1987        fs::write(&target, "\"welcome\" = \"\";\n\"bye\" = \"\";\n").unwrap();
1988        let before = fs::read_to_string(&target).unwrap();
1989
1990        let prepared = prepare_translation(&base_options(&source, Some(&target))).unwrap();
1991        let err = run_prepared_translation(
1992            prepared,
1993            Some(Arc::new(MockBackend::new(vec![
1994                (("welcome", "fr"), Ok("Bienvenue".to_string())),
1995                (("bye", "fr"), Err("boom".to_string())),
1996            ]))),
1997        )
1998        .unwrap_err();
1999
2000        assert!(err.contains("no files were written"));
2001        let after = fs::read_to_string(&target).unwrap();
2002        assert_eq!(before, after);
2003    }
2004
2005    #[test]
2006    fn uses_config_defaults_when_flags_are_missing() {
2007        let temp_dir = TempDir::new().unwrap();
2008        let source = temp_dir.path().join("source.csv");
2009        let config = temp_dir.path().join("langcodec.toml");
2010        fs::write(&source, "key,en,fr\nwelcome,Welcome,\n").unwrap();
2011        fs::write(
2012            &config,
2013            r#"[openai]
2014model = "gpt-5.4"
2015
2016[translate]
2017source_lang = "en"
2018target_lang = ["fr"]
2019concurrency = 2
2020status = ["new", "stale"]
2021"#,
2022        )
2023        .unwrap();
2024
2025        let options = TranslateOptions {
2026            source: Some(source.to_string_lossy().to_string()),
2027            target: None,
2028            output: None,
2029            source_lang: None,
2030            target_langs: Vec::new(),
2031            status: None,
2032            provider: None,
2033            model: None,
2034            concurrency: None,
2035            config: Some(config.to_string_lossy().to_string()),
2036            use_tolgee: false,
2037            tolgee_config: None,
2038            tolgee_namespaces: Vec::new(),
2039            dry_run: true,
2040            strict: false,
2041            ui_mode: UiMode::Plain,
2042        };
2043
2044        let prepared = prepare_translation(&options).unwrap();
2045        assert_eq!(prepared.opts.model.as_deref(), Some("gpt-5.4"));
2046        assert_eq!(prepared.opts.target_langs, vec!["fr".to_string()]);
2047        assert_eq!(prepared.summary.queued, 1);
2048    }
2049
2050    #[test]
2051    fn uses_array_target_langs_from_config() {
2052        let temp_dir = TempDir::new().unwrap();
2053        let source = temp_dir.path().join("source.csv");
2054        let config = temp_dir.path().join("langcodec.toml");
2055        fs::write(&source, "key,en,fr,de\nwelcome,Welcome,,\n").unwrap();
2056        fs::write(
2057            &config,
2058            r#"[openai]
2059model = "gpt-5.4"
2060
2061[translate.input]
2062lang = "en"
2063
2064[translate.output]
2065lang = ["fr", "de"]
2066"#,
2067        )
2068        .unwrap();
2069
2070        let options = TranslateOptions {
2071            source: Some(source.to_string_lossy().to_string()),
2072            target: None,
2073            output: None,
2074            source_lang: None,
2075            target_langs: Vec::new(),
2076            status: None,
2077            provider: None,
2078            model: None,
2079            concurrency: None,
2080            config: Some(config.to_string_lossy().to_string()),
2081            use_tolgee: false,
2082            tolgee_config: None,
2083            tolgee_namespaces: Vec::new(),
2084            dry_run: true,
2085            strict: false,
2086            ui_mode: UiMode::Plain,
2087        };
2088
2089        let prepared = prepare_translation(&options).unwrap();
2090        assert_eq!(
2091            prepared.opts.target_langs,
2092            vec!["fr".to_string(), "de".to_string()]
2093        );
2094        assert_eq!(prepared.summary.queued, 2);
2095    }
2096
2097    #[test]
2098    fn uses_translated_output_status_from_config() {
2099        let temp_dir = TempDir::new().unwrap();
2100        let source = temp_dir.path().join("Localizable.xcstrings");
2101        let config = temp_dir.path().join("langcodec.toml");
2102        fs::write(
2103            &source,
2104            r#"{
2105  "sourceLanguage" : "en",
2106  "version" : "1.0",
2107  "strings" : {
2108    "welcome" : {
2109      "localizations" : {
2110        "en" : {
2111          "stringUnit" : {
2112            "state" : "new",
2113            "value" : "Welcome"
2114          }
2115        }
2116      }
2117    }
2118  }
2119}"#,
2120        )
2121        .unwrap();
2122        fs::write(
2123            &config,
2124            r#"[openai]
2125model = "gpt-5.4"
2126
2127[translate.input]
2128source = "Localizable.xcstrings"
2129lang = "en"
2130
2131[translate.output]
2132lang = ["fr"]
2133status = "translated"
2134"#,
2135        )
2136        .unwrap();
2137
2138        let options = TranslateOptions {
2139            source: None,
2140            target: None,
2141            output: None,
2142            source_lang: None,
2143            target_langs: Vec::new(),
2144            status: None,
2145            provider: None,
2146            model: None,
2147            concurrency: None,
2148            config: Some(config.to_string_lossy().to_string()),
2149            use_tolgee: false,
2150            tolgee_config: None,
2151            tolgee_namespaces: Vec::new(),
2152            dry_run: false,
2153            strict: false,
2154            ui_mode: UiMode::Plain,
2155        };
2156
2157        let runs = expand_translate_invocations(&options).unwrap();
2158        let prepared = prepare_translation(&runs[0]).unwrap();
2159        let output_path = prepared.output_path.clone();
2160        run_prepared_translation(
2161            prepared,
2162            Some(Arc::new(MockBackend::new(vec![(
2163                ("welcome", "fr"),
2164                Ok("Bienvenue".to_string()),
2165            )]))),
2166        )
2167        .unwrap();
2168
2169        let written = fs::read_to_string(output_path).unwrap();
2170        let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
2171        assert_eq!(
2172            parsed["strings"]["welcome"]["localizations"]["fr"]["stringUnit"]["state"],
2173            "translated"
2174        );
2175    }
2176
2177    #[test]
2178    fn rejects_invalid_output_status_from_config() {
2179        let temp_dir = TempDir::new().unwrap();
2180        let source = temp_dir.path().join("source.csv");
2181        let config = temp_dir.path().join("langcodec.toml");
2182        fs::write(&source, "key,en,fr\nwelcome,Welcome,\n").unwrap();
2183        fs::write(
2184            &config,
2185            r#"[openai]
2186model = "gpt-5.4"
2187
2188[translate.input]
2189lang = "en"
2190
2191[translate.output]
2192lang = ["fr"]
2193status = "new"
2194"#,
2195        )
2196        .unwrap();
2197
2198        let options = TranslateOptions {
2199            source: Some(source.to_string_lossy().to_string()),
2200            target: None,
2201            output: None,
2202            source_lang: None,
2203            target_langs: Vec::new(),
2204            status: None,
2205            provider: None,
2206            model: None,
2207            concurrency: None,
2208            config: Some(config.to_string_lossy().to_string()),
2209            use_tolgee: false,
2210            tolgee_config: None,
2211            tolgee_namespaces: Vec::new(),
2212            dry_run: true,
2213            strict: false,
2214            ui_mode: UiMode::Plain,
2215        };
2216
2217        let err = prepare_translation(&options).unwrap_err();
2218        assert!(err.contains("translate output status must be either"));
2219    }
2220
2221    #[test]
2222    fn expands_single_source_from_config_relative_to_config_file() {
2223        let temp_dir = TempDir::new().unwrap();
2224        let config_dir = temp_dir.path().join("project");
2225        fs::create_dir_all(config_dir.join("locales")).unwrap();
2226        fs::create_dir_all(config_dir.join("output")).unwrap();
2227        let config = config_dir.join("langcodec.toml");
2228        fs::write(
2229            &config,
2230            r#"[translate]
2231source = "locales/Localizable.xcstrings"
2232target = "output/Translated.xcstrings"
2233"#,
2234        )
2235        .unwrap();
2236
2237        let runs = expand_translate_invocations(&TranslateOptions {
2238            source: None,
2239            target: None,
2240            output: None,
2241            source_lang: None,
2242            target_langs: Vec::new(),
2243            status: None,
2244            provider: None,
2245            model: None,
2246            concurrency: None,
2247            config: Some(config.to_string_lossy().to_string()),
2248            use_tolgee: false,
2249            tolgee_config: None,
2250            tolgee_namespaces: Vec::new(),
2251            dry_run: true,
2252            strict: false,
2253            ui_mode: UiMode::Plain,
2254        })
2255        .unwrap();
2256
2257        assert_eq!(runs.len(), 1);
2258        assert_eq!(
2259            runs[0].source,
2260            Some(
2261                config_dir
2262                    .join("locales/Localizable.xcstrings")
2263                    .to_string_lossy()
2264                    .to_string()
2265            )
2266        );
2267        assert_eq!(
2268            runs[0].target,
2269            Some(
2270                config_dir
2271                    .join("output/Translated.xcstrings")
2272                    .to_string_lossy()
2273                    .to_string()
2274            )
2275        );
2276    }
2277
2278    #[test]
2279    fn expands_multiple_sources_from_config() {
2280        let temp_dir = TempDir::new().unwrap();
2281        let config_dir = temp_dir.path().join("project");
2282        fs::create_dir_all(&config_dir).unwrap();
2283        let config = config_dir.join("langcodec.toml");
2284        fs::write(
2285            &config,
2286            r#"[translate]
2287sources = ["one.xcstrings", "two.xcstrings"]
2288"#,
2289        )
2290        .unwrap();
2291
2292        let runs = expand_translate_invocations(&TranslateOptions {
2293            source: None,
2294            target: None,
2295            output: None,
2296            source_lang: None,
2297            target_langs: Vec::new(),
2298            status: None,
2299            provider: None,
2300            model: None,
2301            concurrency: None,
2302            config: Some(config.to_string_lossy().to_string()),
2303            use_tolgee: false,
2304            tolgee_config: None,
2305            tolgee_namespaces: Vec::new(),
2306            dry_run: true,
2307            strict: false,
2308            ui_mode: UiMode::Plain,
2309        })
2310        .unwrap();
2311
2312        assert_eq!(runs.len(), 2);
2313        assert_eq!(
2314            runs[0].source,
2315            Some(
2316                config_dir
2317                    .join("one.xcstrings")
2318                    .to_string_lossy()
2319                    .to_string()
2320            )
2321        );
2322        assert_eq!(
2323            runs[1].source,
2324            Some(
2325                config_dir
2326                    .join("two.xcstrings")
2327                    .to_string_lossy()
2328                    .to_string()
2329            )
2330        );
2331    }
2332
2333    #[test]
2334    fn expands_globbed_sources_from_config() {
2335        let temp_dir = TempDir::new().unwrap();
2336        let config_dir = temp_dir.path().join("project");
2337        let feature_a = config_dir.join("Modules").join("FeatureA");
2338        let feature_b = config_dir.join("Modules").join("FeatureB");
2339        fs::create_dir_all(&feature_a).unwrap();
2340        fs::create_dir_all(&feature_b).unwrap();
2341
2342        let first = feature_a.join("Localizable.xcstrings");
2343        let second = feature_b.join("Localizable.xcstrings");
2344        fs::write(
2345            &first,
2346            r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2347        )
2348        .unwrap();
2349        fs::write(
2350            &second,
2351            r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2352        )
2353        .unwrap();
2354
2355        let config = config_dir.join("langcodec.toml");
2356        fs::write(
2357            &config,
2358            r#"[translate.input]
2359sources = ["Modules/*/Localizable.xcstrings"]
2360"#,
2361        )
2362        .unwrap();
2363
2364        let runs = expand_translate_invocations(&TranslateOptions {
2365            source: None,
2366            target: None,
2367            output: None,
2368            source_lang: None,
2369            target_langs: Vec::new(),
2370            status: None,
2371            provider: None,
2372            model: None,
2373            concurrency: None,
2374            config: Some(config.to_string_lossy().to_string()),
2375            use_tolgee: false,
2376            tolgee_config: None,
2377            tolgee_namespaces: Vec::new(),
2378            dry_run: true,
2379            strict: false,
2380            ui_mode: UiMode::Plain,
2381        })
2382        .unwrap();
2383
2384        let mut sources = runs
2385            .into_iter()
2386            .map(|run| run.source.expect("source"))
2387            .collect::<Vec<_>>();
2388        sources.sort();
2389
2390        let mut expected = vec![
2391            first.to_string_lossy().to_string(),
2392            second.to_string_lossy().to_string(),
2393        ];
2394        expected.sort();
2395
2396        assert_eq!(sources, expected);
2397    }
2398
2399    #[test]
2400    fn rejects_target_with_multiple_sources_from_config() {
2401        let temp_dir = TempDir::new().unwrap();
2402        let config = temp_dir.path().join("langcodec.toml");
2403        fs::write(
2404            &config,
2405            r#"[translate]
2406sources = ["one.xcstrings", "two.xcstrings"]
2407target = "translated.xcstrings"
2408"#,
2409        )
2410        .unwrap();
2411
2412        let err = expand_translate_invocations(&TranslateOptions {
2413            source: None,
2414            target: None,
2415            output: None,
2416            source_lang: None,
2417            target_langs: Vec::new(),
2418            status: None,
2419            provider: None,
2420            model: None,
2421            concurrency: None,
2422            config: Some(config.to_string_lossy().to_string()),
2423            use_tolgee: false,
2424            tolgee_config: None,
2425            tolgee_namespaces: Vec::new(),
2426            dry_run: true,
2427            strict: false,
2428            ui_mode: UiMode::Plain,
2429        })
2430        .unwrap_err();
2431
2432        assert!(err.contains("translate.input.sources/translate.sources cannot be combined"));
2433    }
2434
2435    #[test]
2436    fn skips_plural_entries() {
2437        let temp_dir = TempDir::new().unwrap();
2438        let source = temp_dir.path().join("Localizable.xcstrings");
2439        let target = temp_dir.path().join("translated.xcstrings");
2440        fs::write(
2441            &source,
2442            r#"{
2443  "sourceLanguage" : "en",
2444  "version" : "1.0",
2445  "strings" : {
2446    "welcome" : {
2447      "localizations" : {
2448        "en" : {
2449          "stringUnit" : {
2450            "state" : "new",
2451            "value" : "Welcome"
2452          }
2453        }
2454      }
2455    },
2456    "item_count" : {
2457      "localizations" : {
2458        "en" : {
2459          "variations" : {
2460            "plural" : {
2461              "one" : {
2462                "stringUnit" : {
2463                  "state" : "new",
2464                  "value" : "%#@items@"
2465                }
2466              },
2467              "other" : {
2468                "stringUnit" : {
2469                  "state" : "new",
2470                  "value" : "%#@items@"
2471                }
2472              }
2473            }
2474          }
2475        }
2476      }
2477    }
2478  }
2479}"#,
2480        )
2481        .unwrap();
2482
2483        let prepared = prepare_translation(&base_options(&source, Some(&target))).unwrap();
2484        assert_eq!(prepared.summary.skipped_plural, 1);
2485        assert_eq!(prepared.summary.queued, 1);
2486    }
2487
2488    #[test]
2489    fn rejects_in_place_single_language_translation_without_target() {
2490        let temp_dir = TempDir::new().unwrap();
2491        let source = temp_dir.path().join("en.strings");
2492        fs::write(&source, "\"welcome\" = \"Welcome\";\n").unwrap();
2493
2494        let options = base_options(&source, None);
2495        let err = prepare_translation(&options).unwrap_err();
2496        assert!(err.contains("Omitting --target is only supported"));
2497    }
2498
2499    #[test]
2500    fn canonicalizes_target_language_from_existing_target_resource() {
2501        let temp_dir = TempDir::new().unwrap();
2502        let source = temp_dir.path().join("translations.csv");
2503        let target = temp_dir.path().join("target.csv");
2504        fs::write(&source, "key,en\nwelcome,Welcome\n").unwrap();
2505        fs::write(&target, "key,fr-CA\nwelcome,\n").unwrap();
2506
2507        let mut options = base_options(&source, Some(&target));
2508        options.target_langs = vec!["fr".to_string()];
2509        options.source_lang = Some("en".to_string());
2510
2511        let prepared = prepare_translation(&options).unwrap();
2512        assert_eq!(prepared.opts.target_langs, vec!["fr-CA".to_string()]);
2513        assert_eq!(prepared.summary.queued, 1);
2514    }
2515
2516    #[test]
2517    fn infers_status_from_target_input_format_not_output_format() {
2518        let temp_dir = TempDir::new().unwrap();
2519        let source = temp_dir.path().join("en.strings");
2520        let target = temp_dir.path().join("fr.strings");
2521        let output = temp_dir.path().join("translated.xcstrings");
2522
2523        fs::write(&source, "\"welcome\" = \"Welcome\";\n").unwrap();
2524        fs::write(&target, "\"welcome\" = \"\";\n").unwrap();
2525
2526        let mut options = base_options(&source, Some(&target));
2527        options.output = Some(output.to_string_lossy().to_string());
2528
2529        let prepared = prepare_translation(&options).unwrap();
2530        assert_eq!(prepared.summary.queued, 1);
2531    }
2532
2533    #[test]
2534    fn parses_fenced_json_translation() {
2535        let text = "```json\n{\"translation\":\"Bonjour\"}\n```";
2536        let parsed = parse_translation_response(text).unwrap();
2537        assert_eq!(parsed, "Bonjour");
2538    }
2539
2540    #[test]
2541    fn build_prompt_includes_comment_context() {
2542        let prompt = build_prompt(&BackendRequest {
2543            key: "countdown".to_string(),
2544            source_lang: "zh-Hans".to_string(),
2545            target_lang: "fr".to_string(),
2546            source_value: "代码过期倒计时".to_string(),
2547            source_comment: Some("A label displayed below the code expiration timer.".to_string()),
2548        });
2549
2550        assert!(prompt.contains("Comment:"));
2551        assert!(prompt.contains("A label displayed below the code expiration timer."));
2552    }
2553
2554    #[test]
2555    fn translates_multiple_target_languages_into_multilanguage_output() {
2556        let temp_dir = TempDir::new().unwrap();
2557        let source = temp_dir.path().join("Localizable.xcstrings");
2558        fs::write(
2559            &source,
2560            r#"{
2561  "sourceLanguage" : "en",
2562  "version" : "1.0",
2563  "strings" : {
2564    "welcome" : {
2565      "localizations" : {
2566        "en" : {
2567          "stringUnit" : {
2568            "state" : "new",
2569            "value" : "Welcome"
2570          }
2571        }
2572      }
2573    }
2574  }
2575}"#,
2576        )
2577        .unwrap();
2578
2579        let mut options = base_options(&source, None);
2580        options.target_langs = vec!["fr".to_string(), "de".to_string()];
2581
2582        let prepared = prepare_translation(&options).unwrap();
2583        let output_path = prepared.output_path.clone();
2584        assert_eq!(
2585            prepared.opts.target_langs,
2586            vec!["fr".to_string(), "de".to_string()]
2587        );
2588        assert_eq!(prepared.summary.total_entries, 2);
2589        assert_eq!(prepared.summary.queued, 2);
2590
2591        let outcome = run_prepared_translation(
2592            prepared,
2593            Some(Arc::new(MockBackend::new(vec![
2594                (("welcome", "fr"), Ok("Bienvenue".to_string())),
2595                (("welcome", "de"), Ok("Willkommen".to_string())),
2596            ]))),
2597        )
2598        .unwrap();
2599
2600        assert_eq!(outcome.translated, 2);
2601        let written = fs::read_to_string(output_path).unwrap();
2602        assert!(written.contains("\"fr\""));
2603        assert!(written.contains("\"Bienvenue\""));
2604        assert!(written.contains("\"de\""));
2605        assert!(written.contains("\"Willkommen\""));
2606    }
2607
2608    #[test]
2609    fn rejects_multiple_target_languages_for_single_language_output() {
2610        let temp_dir = TempDir::new().unwrap();
2611        let source = temp_dir.path().join("en.strings");
2612        let target = temp_dir.path().join("fr.strings");
2613        fs::write(&source, "\"welcome\" = \"Welcome\";\n").unwrap();
2614
2615        let mut options = base_options(&source, Some(&target));
2616        options.target_langs = vec!["fr".to_string(), "de".to_string()];
2617
2618        let err = prepare_translation(&options).unwrap_err();
2619        assert!(err.contains("Multiple --target-lang values are only supported"));
2620    }
2621
2622    #[test]
2623    fn preserves_catalog_source_language_when_translating_from_non_source_locale() {
2624        let temp_dir = TempDir::new().unwrap();
2625        let source = temp_dir.path().join("Localizable.xcstrings");
2626        fs::write(
2627            &source,
2628            r#"{
2629  "sourceLanguage" : "en",
2630  "version" : "1.0",
2631  "strings" : {
2632    "countdown" : {
2633      "comment" : "A label displayed below the code expiration timer.",
2634      "localizations" : {
2635        "en" : {
2636          "stringUnit" : {
2637            "state" : "translated",
2638            "value" : "Code expired countdown"
2639          }
2640        },
2641        "zh-Hans" : {
2642          "stringUnit" : {
2643            "state" : "translated",
2644            "value" : "代码过期倒计时"
2645          }
2646        }
2647      }
2648    }
2649  }
2650}"#,
2651        )
2652        .unwrap();
2653
2654        let mut options = base_options(&source, None);
2655        options.source_lang = Some("zh-Hans".to_string());
2656        options.target_langs = vec!["fr".to_string()];
2657
2658        let prepared = prepare_translation(&options).unwrap();
2659        let output_path = prepared.output_path.clone();
2660        let outcome = run_prepared_translation(
2661            prepared,
2662            Some(Arc::new(MockBackend::new(vec![(
2663                ("countdown", "fr"),
2664                Ok("Compte a rebours du code expire".to_string()),
2665            )]))),
2666        )
2667        .unwrap();
2668
2669        assert_eq!(outcome.translated, 1);
2670        let written = fs::read_to_string(output_path).unwrap();
2671        let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
2672        assert_eq!(parsed["sourceLanguage"], "en");
2673        assert_eq!(
2674            parsed["strings"]["countdown"]["localizations"]["fr"]["stringUnit"]["value"],
2675            "Compte a rebours du code expire"
2676        );
2677    }
2678
2679    #[test]
2680    fn fails_preflight_before_translation_when_output_cannot_serialize() {
2681        let temp_dir = TempDir::new().unwrap();
2682        let source = temp_dir.path().join("Localizable.xcstrings");
2683        fs::write(
2684            &source,
2685            r#"{
2686  "sourceLanguage" : "en",
2687  "version" : "1.0",
2688  "strings" : {
2689    "welcome" : {
2690      "localizations" : {
2691        "en" : {
2692          "stringUnit" : {
2693            "state" : "translated",
2694            "value" : "Welcome"
2695          }
2696        }
2697      }
2698    }
2699  }
2700}"#,
2701        )
2702        .unwrap();
2703
2704        let prepared = prepare_translation(&base_options(&source, None)).unwrap();
2705        let mut broken = prepared.clone();
2706        broken
2707            .target_codec
2708            .get_mut_by_language("fr")
2709            .unwrap()
2710            .metadata
2711            .custom
2712            .insert("source_language".to_string(), "zh-Hans".to_string());
2713
2714        let err = run_prepared_translation(
2715            broken,
2716            Some(Arc::new(MockBackend::new(vec![(
2717                ("welcome", "fr"),
2718                Ok("Bonjour".to_string()),
2719            )]))),
2720        )
2721        .unwrap_err();
2722        assert!(err.contains("Preflight output validation failed"));
2723        assert!(err.contains("Source language mismatch"));
2724    }
2725
2726    #[test]
2727    fn tolgee_prefill_uses_ai_fallback_and_pushes_namespace() {
2728        let temp_dir = TempDir::new().unwrap();
2729        let project_root = temp_dir.path();
2730        let source = project_root.join("Localizable.xcstrings");
2731        let payload = project_root.join("pull_payload.xcstrings");
2732        let capture = project_root.join("captured_config.json");
2733        let log = project_root.join("tolgee.log");
2734
2735        write_translate_source_catalog(&source);
2736        write_translate_tolgee_payload(&payload);
2737        let tolgee_config = write_translate_tolgee_config(project_root);
2738        write_fake_tolgee(project_root, &payload, &capture, &log);
2739
2740        let mut options = base_options(&source, None);
2741        options.target_langs = vec!["fr".to_string()];
2742        options.provider = Some("openai".to_string());
2743        options.model = Some("gpt-4.1-mini".to_string());
2744        options.use_tolgee = true;
2745        options.tolgee_config = Some(tolgee_config.to_string_lossy().to_string());
2746        options.tolgee_namespaces = vec!["Core".to_string()];
2747
2748        let prepared = prepare_translation(&options).unwrap();
2749        assert_eq!(prepared.jobs.len(), 1);
2750        assert_eq!(prepared.jobs[0].key, "bye");
2751
2752        let outcome = run_prepared_translation(
2753            prepared,
2754            Some(Arc::new(MockBackend::new(vec![(
2755                ("bye", "fr"),
2756                Ok("Au revoir".to_string()),
2757            )]))),
2758        )
2759        .unwrap();
2760
2761        assert_eq!(outcome.translated, 1);
2762        let written = fs::read_to_string(&source).unwrap();
2763        assert!(written.contains("\"Bienvenue\""));
2764        assert!(written.contains("\"Au revoir\""));
2765
2766        let log_contents = fs::read_to_string(&log).unwrap();
2767        assert!(log_contents.contains("pull|"));
2768        assert!(log_contents.contains("push|"));
2769
2770        let captured = fs::read_to_string(&capture).unwrap();
2771        assert!(captured.contains("\"namespaces\""));
2772        assert!(captured.contains("\"Core\""));
2773    }
2774
2775    #[test]
2776    fn falls_back_to_xcstrings_key_when_source_locale_entry_is_missing() {
2777        let temp_dir = TempDir::new().unwrap();
2778        let source = temp_dir.path().join("Localizable.xcstrings");
2779        fs::write(
2780            &source,
2781            r#"{
2782  "sourceLanguage" : "en",
2783  "version" : "1.0",
2784  "strings" : {
2785    "99+ users have won tons of blue diamonds here" : {
2786      "localizations" : {
2787        "tr" : {
2788          "stringUnit" : {
2789            "state" : "translated",
2790            "value" : "99+ kullanici burada tonlarca mavi elmas kazandi"
2791          }
2792        }
2793      }
2794    }
2795  }
2796}"#,
2797        )
2798        .unwrap();
2799
2800        let mut options = base_options(&source, None);
2801        options.source_lang = Some("en".to_string());
2802        options.target_langs = vec!["zh-Hans".to_string()];
2803
2804        let prepared = prepare_translation(&options).unwrap();
2805        assert_eq!(prepared.summary.queued, 1);
2806        assert_eq!(
2807            prepared.jobs[0].source_value,
2808            "99+ users have won tons of blue diamonds here"
2809        );
2810    }
2811}