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::Xliff(_) => Err(
1029            "XLIFF output is not supported by `translate` in v1. Translate into .xcstrings, .strings, or strings.xml first."
1030                .to_string(),
1031        ),
1032        FormatType::CSV => {
1033            let format = CSVFormat::try_from(codec.resources.clone())
1034                .map_err(|e| format!("Error building CSV output: {}", e))?;
1035            let mut out = Vec::new();
1036            format
1037                .to_writer(&mut out)
1038                .map_err(|e| format!("Error serializing CSV output: {}", e))
1039        }
1040        FormatType::TSV => {
1041            let format = TSVFormat::try_from(codec.resources.clone())
1042                .map_err(|e| format!("Error building TSV output: {}", e))?;
1043            let mut out = Vec::new();
1044            format
1045                .to_writer(&mut out)
1046                .map_err(|e| format!("Error serializing TSV output: {}", e))
1047        }
1048    }
1049    .map_err(|err| format!("{} ({})", err, output_path))
1050}
1051
1052fn build_jobs(
1053    source: &Resource,
1054    target_codec: &Codec,
1055    target_langs: &[String],
1056    statuses: &[EntryStatus],
1057    explicit_target_status: bool,
1058) -> Result<(Vec<TranslationJob>, TranslationSummary), String> {
1059    let mut jobs = Vec::new();
1060    let mut summary = TranslationSummary {
1061        total_entries: source.entries.len() * target_langs.len(),
1062        ..TranslationSummary::default()
1063    };
1064
1065    for target_lang in target_langs {
1066        for entry in &source.entries {
1067            if entry.status == EntryStatus::DoNotTranslate {
1068                summary.skipped_do_not_translate += 1;
1069                continue;
1070            }
1071
1072            let source_text = match &entry.value {
1073                Translation::Plural(_) => {
1074                    summary.skipped_plural += 1;
1075                    continue;
1076                }
1077                Translation::Empty => {
1078                    summary.skipped_empty_source += 1;
1079                    continue;
1080                }
1081                Translation::Singular(text) if text.trim().is_empty() => {
1082                    summary.skipped_empty_source += 1;
1083                    continue;
1084                }
1085                Translation::Singular(text) => text,
1086            };
1087
1088            let target_entry = target_codec.find_entry(&entry.id, target_lang);
1089
1090            if target_entry.is_some_and(|item| item.status == EntryStatus::DoNotTranslate) {
1091                summary.skipped_do_not_translate += 1;
1092                continue;
1093            }
1094
1095            let effective_status = target_entry
1096                .map(|item| effective_target_status(item, explicit_target_status))
1097                .unwrap_or(EntryStatus::New);
1098
1099            if !statuses.contains(&effective_status) {
1100                summary.skipped_status += 1;
1101                continue;
1102            }
1103
1104            jobs.push(TranslationJob {
1105                key: entry.id.clone(),
1106                source_lang: source.metadata.language.clone(),
1107                target_lang: target_lang.clone(),
1108                source_value: source_text.clone(),
1109                source_comment: entry.comment.clone(),
1110                existing_comment: target_entry.and_then(|item| item.comment.clone()),
1111            });
1112            summary.queued += 1;
1113        }
1114    }
1115
1116    Ok((jobs, summary))
1117}
1118
1119fn effective_target_status(entry: &Entry, explicit_target_status: bool) -> EntryStatus {
1120    if explicit_target_status {
1121        return entry.status.clone();
1122    }
1123
1124    match &entry.value {
1125        Translation::Empty => EntryStatus::New,
1126        Translation::Singular(text) if text.trim().is_empty() => EntryStatus::New,
1127        _ => EntryStatus::Translated,
1128    }
1129}
1130
1131fn ensure_target_resource(codec: &mut Codec, language: &str) -> Result<(), String> {
1132    if codec.get_by_language(language).is_none() {
1133        codec.add_resource(Resource {
1134            metadata: Metadata {
1135                language: language.to_string(),
1136                domain: String::new(),
1137                custom: HashMap::new(),
1138            },
1139            entries: Vec::new(),
1140        });
1141    }
1142    Ok(())
1143}
1144
1145fn ensure_resource_exists(
1146    codec: &mut Codec,
1147    resource: &Resource,
1148    language: &str,
1149    clone_entries: bool,
1150) {
1151    if codec.get_by_language(language).is_some() {
1152        return;
1153    }
1154
1155    codec.add_resource(Resource {
1156        metadata: resource.metadata.clone(),
1157        entries: if clone_entries {
1158            resource.entries.clone()
1159        } else {
1160            Vec::new()
1161        },
1162    });
1163}
1164
1165fn propagate_xcstrings_metadata(codec: &mut Codec, source_resource: &Resource) {
1166    let source_language = source_resource
1167        .metadata
1168        .custom
1169        .get("source_language")
1170        .cloned()
1171        .unwrap_or_else(|| source_resource.metadata.language.clone());
1172    let version = source_resource
1173        .metadata
1174        .custom
1175        .get("version")
1176        .cloned()
1177        .unwrap_or_else(|| "1.0".to_string());
1178
1179    for resource in &mut codec.resources {
1180        resource
1181            .metadata
1182            .custom
1183            .entry("source_language".to_string())
1184            .or_insert_with(|| source_language.to_string());
1185        resource
1186            .metadata
1187            .custom
1188            .entry("version".to_string())
1189            .or_insert_with(|| version.clone());
1190    }
1191}
1192
1193fn validate_path_inputs(opts: &ResolvedOptions) -> Result<(), String> {
1194    if !Path::new(&opts.source).is_file() {
1195        return Err(format!("Source file does not exist: {}", opts.source));
1196    }
1197
1198    if let Some(target) = &opts.target {
1199        if Path::new(target).exists() && !Path::new(target).is_file() {
1200            return Err(format!("Target path is not a file: {}", target));
1201        }
1202        validate_output_path(target)?;
1203    }
1204
1205    if let Some(output) = &opts.output {
1206        validate_output_path(output)?;
1207    }
1208
1209    Ok(())
1210}
1211
1212fn resolve_options(
1213    opts: &TranslateOptions,
1214    config: Option<&LoadedConfig>,
1215) -> Result<ResolvedOptions, String> {
1216    let cfg = config.map(|item| &item.data.translate);
1217    let tolgee_cfg = config.map(|item| &item.data.tolgee);
1218    let config_dir = config.and_then(LoadedConfig::config_dir);
1219    let source_lang = opts
1220        .source_lang
1221        .clone()
1222        .or_else(|| cfg.and_then(|item| item.resolved_source_lang().map(str::to_string)));
1223    let target_langs = if !opts.target_langs.is_empty() {
1224        parse_language_list(opts.target_langs.iter().map(String::as_str))?
1225    } else {
1226        parse_language_list(
1227            cfg.and_then(|item| item.resolved_target_langs())
1228                .into_iter()
1229                .flatten()
1230                .flat_map(|value| value.split(',')),
1231        )?
1232    };
1233    if target_langs.is_empty() {
1234        return Err(
1235            "--target-lang is required (or set translate.output.lang/translate.target_lang in langcodec.toml)"
1236                .to_string(),
1237        );
1238    }
1239    if let Some(lang) = &source_lang {
1240        validate_language_code(lang)?;
1241    }
1242
1243    let use_tolgee = opts.use_tolgee
1244        || opts.tolgee_config.is_some()
1245        || !opts.tolgee_namespaces.is_empty()
1246        || cfg.and_then(|item| item.use_tolgee).unwrap_or(false);
1247
1248    let tolgee_config = opts.tolgee_config.clone().or_else(|| {
1249        tolgee_cfg
1250            .and_then(|item| item.config.as_deref())
1251            .map(|path| resolve_config_relative_path(config_dir, path))
1252    });
1253    let tolgee_namespaces = if !opts.tolgee_namespaces.is_empty() {
1254        opts.tolgee_namespaces.clone()
1255    } else {
1256        tolgee_cfg
1257            .and_then(|item| item.namespaces.clone())
1258            .unwrap_or_default()
1259    };
1260
1261    let provider_resolution = resolve_provider(
1262        opts.provider.as_deref(),
1263        config.map(|item| &item.data),
1264        cfg.and_then(|item| item.provider.as_deref()),
1265    );
1266    let (provider, provider_error) = match provider_resolution {
1267        Ok(provider) => (Some(provider), None),
1268        Err(err) if use_tolgee => (None, Some(err)),
1269        Err(err) => return Err(err),
1270    };
1271    let (model, model_error) = if let Some(provider) = provider.as_ref() {
1272        match resolve_model(
1273            opts.model.as_deref(),
1274            config.map(|item| &item.data),
1275            provider,
1276            cfg.and_then(|item| item.model.as_deref()),
1277        ) {
1278            Ok(model) => (Some(model), None),
1279            Err(err) if use_tolgee => (None, Some(err)),
1280            Err(err) => return Err(err),
1281        }
1282    } else {
1283        (None, None)
1284    };
1285
1286    let concurrency = opts
1287        .concurrency
1288        .or_else(|| cfg.and_then(|item| item.concurrency))
1289        .unwrap_or(DEFAULT_CONCURRENCY);
1290    if concurrency == 0 {
1291        return Err("Concurrency must be greater than zero".to_string());
1292    }
1293
1294    let statuses = parse_status_filter(
1295        opts.status.as_deref(),
1296        cfg.and_then(|item| item.resolved_filter_status()),
1297    )?;
1298    let output_status = parse_output_status(cfg.and_then(|item| item.resolved_output_status()))?;
1299    let ui_mode = resolve_ui_mode_for_current_terminal(opts.ui_mode)?;
1300
1301    Ok(ResolvedOptions {
1302        source: opts
1303            .source
1304            .clone()
1305            .ok_or_else(|| "--source is required".to_string())?,
1306        target: opts.target.clone(),
1307        output: opts.output.clone(),
1308        source_lang,
1309        target_langs,
1310        statuses,
1311        output_status,
1312        provider,
1313        model,
1314        provider_error,
1315        model_error,
1316        concurrency,
1317        use_tolgee,
1318        tolgee_config,
1319        tolgee_namespaces,
1320        dry_run: opts.dry_run,
1321        strict: opts.strict,
1322        ui_mode,
1323    })
1324}
1325
1326fn parse_status_filter(
1327    cli: Option<&str>,
1328    cfg: Option<&Vec<String>>,
1329) -> Result<Vec<EntryStatus>, String> {
1330    let raw_values: Vec<String> = if let Some(cli) = cli {
1331        cli.split(',')
1332            .map(str::trim)
1333            .filter(|value| !value.is_empty())
1334            .map(ToOwned::to_owned)
1335            .collect()
1336    } else if let Some(cfg) = cfg {
1337        cfg.clone()
1338    } else {
1339        DEFAULT_STATUSES
1340            .iter()
1341            .map(|value| value.to_string())
1342            .collect()
1343    };
1344
1345    let mut statuses = Vec::new();
1346    for raw in raw_values {
1347        let normalized = raw.replace(['-', ' '], "_");
1348        let parsed = normalized
1349            .parse::<EntryStatus>()
1350            .map_err(|e| format!("Invalid translate status '{}': {}", raw, e))?;
1351        if !statuses.contains(&parsed) {
1352            statuses.push(parsed);
1353        }
1354    }
1355    Ok(statuses)
1356}
1357
1358fn parse_output_status(raw: Option<&str>) -> Result<EntryStatus, String> {
1359    let Some(raw) = raw else {
1360        return Ok(EntryStatus::NeedsReview);
1361    };
1362
1363    let normalized = raw.trim().replace(['-', ' '], "_");
1364    let parsed = normalized
1365        .parse::<EntryStatus>()
1366        .map_err(|e| format!("Invalid translate output_status '{}': {}", raw, e))?;
1367
1368    match parsed {
1369        EntryStatus::NeedsReview | EntryStatus::Translated => Ok(parsed),
1370        _ => Err(format!(
1371            "translate output status must be either 'needs_review' or 'translated', got '{}'",
1372            raw
1373        )),
1374    }
1375}
1376
1377fn parse_language_list<'a, I>(values: I) -> Result<Vec<String>, String>
1378where
1379    I: IntoIterator<Item = &'a str>,
1380{
1381    let mut parsed: Vec<String> = Vec::new();
1382    for raw in values {
1383        let value = raw.trim();
1384        if value.is_empty() {
1385            continue;
1386        }
1387        validate_language_code(value)?;
1388        if !parsed
1389            .iter()
1390            .any(|existing| normalize_lang(existing) == normalize_lang(value))
1391        {
1392            parsed.push(value.to_string());
1393        }
1394    }
1395    Ok(parsed)
1396}
1397
1398fn read_codec(path: &str, language_hint: Option<String>, strict: bool) -> Result<Codec, String> {
1399    let mut codec = Codec::new();
1400    codec
1401        .read_file_by_extension_with_options(
1402            path,
1403            &ReadOptions::new()
1404                .with_language_hint(language_hint)
1405                .with_strict(strict),
1406        )
1407        .map_err(|e| format!("Failed to read '{}': {}", path, e))?;
1408    Ok(codec)
1409}
1410
1411fn select_source_resource(
1412    codec: &Codec,
1413    requested_lang: &Option<String>,
1414) -> Result<SelectedResource, String> {
1415    if let Some(lang) = requested_lang {
1416        if let Some(resource) = codec
1417            .resources
1418            .iter()
1419            .find(|item| lang_matches(&item.metadata.language, lang))
1420            .cloned()
1421        {
1422            return Ok(SelectedResource {
1423                language: resource.metadata.language.clone(),
1424                resource,
1425            });
1426        }
1427
1428        return Err(format!("Source language '{}' not found", lang));
1429    }
1430
1431    if codec.resources.len() == 1 {
1432        let resource = codec.resources[0].clone();
1433        return Ok(SelectedResource {
1434            language: resource.metadata.language.clone(),
1435            resource,
1436        });
1437    }
1438
1439    Err("Multiple source languages present; specify --source-lang".to_string())
1440}
1441
1442fn resolve_target_languages(
1443    codec: &Codec,
1444    requested_langs: &[String],
1445    inferred_from_output: Option<&str>,
1446) -> Result<Vec<String>, String> {
1447    let mut resolved: Vec<String> = Vec::new();
1448
1449    for requested_lang in requested_langs {
1450        let canonical = if let Some(resource) = codec
1451            .resources
1452            .iter()
1453            .find(|item| lang_matches(&item.metadata.language, requested_lang))
1454        {
1455            resource.metadata.language.clone()
1456        } else if let Some(inferred) = inferred_from_output
1457            && lang_matches(inferred, requested_lang)
1458        {
1459            inferred.to_string()
1460        } else {
1461            requested_lang.to_string()
1462        };
1463
1464        if !resolved
1465            .iter()
1466            .any(|existing| normalize_lang(existing) == normalize_lang(&canonical))
1467        {
1468            resolved.push(canonical);
1469        }
1470    }
1471
1472    Ok(resolved)
1473}
1474
1475fn lang_matches(resource_lang: &str, requested_lang: &str) -> bool {
1476    normalize_lang(resource_lang) == normalize_lang(requested_lang)
1477        || normalize_lang(resource_lang)
1478            .split('-')
1479            .next()
1480            .unwrap_or(resource_lang)
1481            == normalize_lang(requested_lang)
1482                .split('-')
1483                .next()
1484                .unwrap_or(requested_lang)
1485}
1486
1487fn normalize_lang(lang: &str) -> String {
1488    lang.trim().replace('_', "-").to_ascii_lowercase()
1489}
1490
1491fn is_multi_language_format(format: &FormatType) -> bool {
1492    matches!(
1493        format,
1494        FormatType::Xcstrings | FormatType::CSV | FormatType::TSV
1495    )
1496}
1497
1498fn target_supports_explicit_status(path: &str) -> bool {
1499    Path::new(path)
1500        .extension()
1501        .and_then(|ext| ext.to_str())
1502        .is_some_and(|ext| ext.eq_ignore_ascii_case("xcstrings"))
1503}
1504
1505fn single_output_language(target_langs: &[String]) -> Option<&str> {
1506    if target_langs.len() == 1 {
1507        Some(target_langs[0].as_str())
1508    } else {
1509        None
1510    }
1511}
1512
1513fn write_back(
1514    codec: &Codec,
1515    output_path: &str,
1516    output_format: &FormatType,
1517    target_lang: Option<&str>,
1518) -> Result<(), String> {
1519    match output_format {
1520        FormatType::Strings(_) | FormatType::AndroidStrings(_) => {
1521            let target_lang = target_lang.ok_or_else(|| {
1522                "Single-language outputs require exactly one target language".to_string()
1523            })?;
1524            let resource = codec
1525                .resources
1526                .iter()
1527                .find(|item| lang_matches(&item.metadata.language, target_lang))
1528                .ok_or_else(|| format!("Target language '{}' not found in output", target_lang))?;
1529            Codec::write_resource_to_file(resource, output_path)
1530                .map_err(|e| format!("Error writing output: {}", e))
1531        }
1532        FormatType::Xcstrings | FormatType::CSV | FormatType::TSV => {
1533            convert_resources_to_format(codec.resources.clone(), output_path, output_format.clone())
1534                .map_err(|e| format!("Error writing output: {}", e))
1535        }
1536        FormatType::Xliff(_) => Err(
1537            "XLIFF output is not supported by `translate` in v1. Translate into .xcstrings, .strings, or strings.xml first."
1538                .to_string(),
1539        ),
1540    }
1541}
1542
1543fn create_mentra_backend(opts: &ResolvedOptions) -> Result<MentraBackend, String> {
1544    let provider = opts.provider.as_ref().ok_or_else(|| {
1545        opts.provider_error.clone().unwrap_or_else(|| {
1546            "--provider is required when Tolgee prefill does not satisfy all translations"
1547                .to_string()
1548        })
1549    })?;
1550    let model = opts.model.as_ref().ok_or_else(|| {
1551        opts.model_error.clone().unwrap_or_else(|| {
1552            "--model is required when Tolgee prefill does not satisfy all translations".to_string()
1553        })
1554    })?;
1555    let setup = build_provider(provider)?;
1556    if setup.provider_kind != *provider {
1557        return Err("Resolved provider mismatch".to_string());
1558    }
1559    Ok(MentraBackend {
1560        provider: setup.provider,
1561        model: model.clone(),
1562    })
1563}
1564
1565fn translate_engine_label(opts: &ResolvedOptions) -> String {
1566    let ai_label = opts
1567        .provider
1568        .as_ref()
1569        .zip(opts.model.as_ref())
1570        .map(|(provider, model)| format!("{}:{}", provider.display_name(), model));
1571
1572    match (opts.use_tolgee, ai_label) {
1573        (true, Some(ai_label)) => format!("tolgee + {}", ai_label),
1574        (true, None) => "tolgee".to_string(),
1575        (false, Some(ai_label)) => ai_label,
1576        (false, None) => "unconfigured".to_string(),
1577    }
1578}
1579
1580fn build_prompt(request: &BackendRequest) -> String {
1581    let mut prompt = format!(
1582        "Translate the following localization value from {} to {}.\nKey: {}\nSource value:\n{}\n",
1583        request.source_lang, request.target_lang, request.key, request.source_value
1584    );
1585    if let Some(comment) = &request.source_comment {
1586        prompt.push_str("\nComment:\n");
1587        prompt.push_str(comment);
1588        prompt.push('\n');
1589    }
1590    prompt.push_str(
1591        "\nReturn JSON only in this exact shape: {\"translation\":\"...\"}. Do not wrap in markdown fences unless necessary.",
1592    );
1593    prompt
1594}
1595
1596fn collect_text_blocks(response: &provider::Response) -> String {
1597    response
1598        .content
1599        .iter()
1600        .filter_map(|block| match block {
1601            ContentBlock::Text { text } => Some(text.as_str()),
1602            _ => None,
1603        })
1604        .collect::<Vec<_>>()
1605        .join("")
1606}
1607
1608fn parse_translation_response(text: &str) -> Result<String, String> {
1609    let trimmed = text.trim();
1610    if trimmed.is_empty() {
1611        return Err("Model returned an empty translation".to_string());
1612    }
1613
1614    if let Ok(payload) = serde_json::from_str::<ModelTranslationPayload>(trimmed) {
1615        return Ok(payload.translation);
1616    }
1617
1618    if let Some(json_body) = extract_json_body(trimmed)
1619        && let Ok(payload) = serde_json::from_str::<ModelTranslationPayload>(&json_body)
1620    {
1621        return Ok(payload.translation);
1622    }
1623
1624    Err(format!(
1625        "Model response was not valid translation JSON: {}",
1626        trimmed
1627    ))
1628}
1629
1630fn extract_json_body(text: &str) -> Option<String> {
1631    let fenced = text
1632        .strip_prefix("```json")
1633        .or_else(|| text.strip_prefix("```"))
1634        .map(str::trim_start)?;
1635    let unfenced = fenced.strip_suffix("```")?.trim();
1636    Some(unfenced.to_string())
1637}
1638
1639fn format_provider_error(err: ProviderError) -> String {
1640    format!("Provider request failed: {}", err)
1641}
1642
1643#[cfg(test)]
1644mod tests {
1645    use super::*;
1646    use std::{collections::VecDeque, fs, path::PathBuf, sync::Mutex};
1647    use tempfile::TempDir;
1648
1649    type MockResponseKey = (String, String);
1650    type MockResponse = Result<String, String>;
1651    type MockResponseQueue = VecDeque<MockResponse>;
1652    type MockResponseMap = HashMap<MockResponseKey, MockResponseQueue>;
1653    type MockResponseSeed = ((&'static str, &'static str), MockResponse);
1654
1655    #[derive(Clone)]
1656    struct MockBackend {
1657        responses: Arc<Mutex<MockResponseMap>>,
1658    }
1659
1660    impl MockBackend {
1661        fn new(responses: Vec<MockResponseSeed>) -> Self {
1662            let mut mapped = HashMap::new();
1663            for ((key, target_lang), value) in responses {
1664                mapped
1665                    .entry((key.to_string(), target_lang.to_string()))
1666                    .or_insert_with(VecDeque::new)
1667                    .push_back(value);
1668            }
1669            Self {
1670                responses: Arc::new(Mutex::new(mapped)),
1671            }
1672        }
1673    }
1674
1675    #[async_trait]
1676    impl TranslationBackend for MockBackend {
1677        async fn translate(&self, request: BackendRequest) -> Result<String, String> {
1678            self.responses
1679                .lock()
1680                .unwrap()
1681                .get_mut(&(request.key.clone(), request.target_lang.clone()))
1682                .and_then(|values| values.pop_front())
1683                .unwrap_or_else(|| Err("missing mock response".to_string()))
1684        }
1685    }
1686
1687    fn base_options(source: &Path, target: Option<&Path>) -> TranslateOptions {
1688        TranslateOptions {
1689            source: Some(source.to_string_lossy().to_string()),
1690            target: target.map(|path| path.to_string_lossy().to_string()),
1691            output: None,
1692            source_lang: Some("en".to_string()),
1693            target_langs: vec!["fr".to_string()],
1694            status: None,
1695            provider: Some("openai".to_string()),
1696            model: Some("gpt-4.1-mini".to_string()),
1697            concurrency: Some(2),
1698            config: None,
1699            use_tolgee: false,
1700            tolgee_config: None,
1701            tolgee_namespaces: Vec::new(),
1702            dry_run: false,
1703            strict: false,
1704            ui_mode: UiMode::Plain,
1705        }
1706    }
1707
1708    #[cfg(unix)]
1709    fn make_executable(path: &Path) {
1710        use std::os::unix::fs::PermissionsExt;
1711
1712        let mut perms = fs::metadata(path).unwrap().permissions();
1713        perms.set_mode(0o755);
1714        fs::set_permissions(path, perms).unwrap();
1715    }
1716
1717    fn write_fake_tolgee(
1718        project_root: &Path,
1719        payload_path: &Path,
1720        capture_path: &Path,
1721        log_path: &Path,
1722    ) {
1723        let bin_dir = project_root.join("node_modules/.bin");
1724        fs::create_dir_all(&bin_dir).unwrap();
1725        let script_path = bin_dir.join("tolgee");
1726        let script = format!(
1727            r#"#!/bin/sh
1728config=""
1729subcommand=""
1730while [ "$#" -gt 0 ]; do
1731  case "$1" in
1732    --config)
1733      config="$2"
1734      shift 2
1735      ;;
1736    pull|push)
1737      subcommand="$1"
1738      shift
1739      ;;
1740    *)
1741      shift
1742      ;;
1743  esac
1744done
1745
1746echo "$subcommand|$config" >> "{log_path}"
1747cp "$config" "{capture_path}"
1748
1749if [ "$subcommand" = "push" ]; then
1750  exit 0
1751fi
1752
1753eval "$(
1754python3 - "$config" <<'PY'
1755import json
1756import shlex
1757import sys
1758
1759with open(sys.argv[1], "r", encoding="utf-8") as fh:
1760    data = json.load(fh)
1761
1762pull_path = data.get("pull", {{}}).get("path", "")
1763namespaces = data.get("pull", {{}}).get("namespaces") or data.get("push", {{}}).get("namespaces") or []
1764if namespaces:
1765    namespace = namespaces[0]
1766else:
1767    files = data.get("push", {{}}).get("files") or []
1768    namespace = files[0]["namespace"] if files else ""
1769
1770print(f"pull_path={{shlex.quote(pull_path)}}")
1771print(f"namespace={{shlex.quote(namespace)}}")
1772PY
1773)"
1774mkdir -p "$pull_path/$namespace"
1775cp "{payload_path}" "$pull_path/$namespace/Localizable.xcstrings"
1776"#,
1777            payload_path = payload_path.display(),
1778            capture_path = capture_path.display(),
1779            log_path = log_path.display(),
1780        );
1781        fs::write(&script_path, script).unwrap();
1782        #[cfg(unix)]
1783        make_executable(&script_path);
1784    }
1785
1786    fn write_translate_tolgee_config(project_root: &Path) -> PathBuf {
1787        let config_path = project_root.join(".tolgeerc.json");
1788        fs::write(
1789            &config_path,
1790            r#"{
1791  "format": "APPLE_XCSTRINGS",
1792  "push": {
1793    "files": [
1794      {
1795        "path": "Localizable.xcstrings",
1796        "namespace": "Core"
1797      }
1798    ]
1799  },
1800  "pull": {
1801    "path": "./tolgee-temp",
1802    "fileStructureTemplate": "/{namespace}/Localizable.{extension}"
1803  }
1804}"#,
1805        )
1806        .unwrap();
1807        config_path
1808    }
1809
1810    fn write_translate_source_catalog(path: &Path) {
1811        fs::write(
1812            path,
1813            r#"{
1814  "sourceLanguage" : "en",
1815  "version" : "1.0",
1816  "strings" : {
1817    "welcome" : {
1818      "localizations" : {
1819        "en" : {
1820          "stringUnit" : {
1821            "state" : "translated",
1822            "value" : "Welcome"
1823          }
1824        }
1825      }
1826    },
1827    "bye" : {
1828      "localizations" : {
1829        "en" : {
1830          "stringUnit" : {
1831            "state" : "translated",
1832            "value" : "Goodbye"
1833          }
1834        }
1835      }
1836    }
1837  }
1838}"#,
1839        )
1840        .unwrap();
1841    }
1842
1843    fn write_translate_tolgee_payload(path: &Path) {
1844        fs::write(
1845            path,
1846            r#"{
1847  "sourceLanguage" : "en",
1848  "version" : "1.0",
1849  "strings" : {
1850    "welcome" : {
1851      "localizations" : {
1852        "fr" : {
1853          "stringUnit" : {
1854            "state" : "translated",
1855            "value" : "Bienvenue"
1856          }
1857        }
1858      }
1859    }
1860  }
1861}"#,
1862        )
1863        .unwrap();
1864    }
1865
1866    #[test]
1867    fn translates_missing_entries_into_target_file() {
1868        let temp_dir = TempDir::new().unwrap();
1869        let source = temp_dir.path().join("en.strings");
1870        let target = temp_dir.path().join("fr.strings");
1871
1872        fs::write(
1873            &source,
1874            "\"welcome\" = \"Welcome\";\n\"bye\" = \"Goodbye\";\n",
1875        )
1876        .unwrap();
1877
1878        let prepared = prepare_translation(&base_options(&source, Some(&target))).unwrap();
1879        let outcome = run_prepared_translation(
1880            prepared,
1881            Some(Arc::new(MockBackend::new(vec![
1882                (("welcome", "fr"), Ok("Bienvenue".to_string())),
1883                (("bye", "fr"), Ok("Au revoir".to_string())),
1884            ]))),
1885        )
1886        .unwrap();
1887
1888        assert_eq!(outcome.translated, 2);
1889        let written = fs::read_to_string(&target).unwrap();
1890        assert!(written.contains("\"welcome\" = \"Bienvenue\";"));
1891        assert!(written.contains("\"bye\" = \"Au revoir\";"));
1892    }
1893
1894    #[test]
1895    fn translates_strings_source_into_android_target_file() {
1896        let temp_dir = TempDir::new().unwrap();
1897        let source = temp_dir.path().join("en.strings");
1898        let target_dir = temp_dir.path().join("values-fr");
1899        let target = target_dir.join("strings.xml");
1900        fs::create_dir_all(&target_dir).unwrap();
1901        fs::write(
1902            &source,
1903            "\"welcome\" = \"Welcome\";\n\"bye\" = \"Goodbye\";\n",
1904        )
1905        .unwrap();
1906
1907        let prepared = prepare_translation(&base_options(&source, Some(&target))).unwrap();
1908        let outcome = run_prepared_translation(
1909            prepared,
1910            Some(Arc::new(MockBackend::new(vec![
1911                (("welcome", "fr"), Ok("Bienvenue".to_string())),
1912                (("bye", "fr"), Ok("Au revoir".to_string())),
1913            ]))),
1914        )
1915        .unwrap();
1916
1917        assert_eq!(outcome.translated, 2);
1918        let written = fs::read_to_string(&target).unwrap();
1919        assert!(written.contains("<string name=\"welcome\">Bienvenue</string>"));
1920        assert!(written.contains("<string name=\"bye\">Au revoir</string>"));
1921    }
1922
1923    #[test]
1924    fn translates_android_source_into_strings_target_file() {
1925        let temp_dir = TempDir::new().unwrap();
1926        let source_dir = temp_dir.path().join("values");
1927        let source = source_dir.join("strings.xml");
1928        let target = temp_dir.path().join("fr.strings");
1929        fs::create_dir_all(&source_dir).unwrap();
1930        fs::write(
1931            &source,
1932            r#"<resources>
1933<string name="welcome">Welcome</string>
1934<string name="bye">Goodbye</string>
1935</resources>
1936"#,
1937        )
1938        .unwrap();
1939
1940        let prepared = prepare_translation(&base_options(&source, Some(&target))).unwrap();
1941        let outcome = run_prepared_translation(
1942            prepared,
1943            Some(Arc::new(MockBackend::new(vec![
1944                (("welcome", "fr"), Ok("Bienvenue".to_string())),
1945                (("bye", "fr"), Ok("Au revoir".to_string())),
1946            ]))),
1947        )
1948        .unwrap();
1949
1950        assert_eq!(outcome.translated, 2);
1951        let written = fs::read_to_string(&target).unwrap();
1952        assert!(written.contains("\"welcome\" = \"Bienvenue\";"));
1953        assert!(written.contains("\"bye\" = \"Au revoir\";"));
1954    }
1955
1956    #[test]
1957    fn dry_run_does_not_write_target() {
1958        let temp_dir = TempDir::new().unwrap();
1959        let source = temp_dir.path().join("en.strings");
1960        let target = temp_dir.path().join("fr.strings");
1961
1962        fs::write(&source, "\"welcome\" = \"Welcome\";\n").unwrap();
1963        fs::write(&target, "\"welcome\" = \"\";\n").unwrap();
1964
1965        let mut options = base_options(&source, Some(&target));
1966        options.dry_run = true;
1967
1968        let before = fs::read_to_string(&target).unwrap();
1969        let prepared = prepare_translation(&options).unwrap();
1970        let outcome = run_prepared_translation(
1971            prepared,
1972            Some(Arc::new(MockBackend::new(vec![(
1973                ("welcome", "fr"),
1974                Ok("Bienvenue".to_string()),
1975            )]))),
1976        )
1977        .unwrap();
1978        let after = fs::read_to_string(&target).unwrap();
1979
1980        assert_eq!(outcome.translated, 1);
1981        assert_eq!(before, after);
1982    }
1983
1984    #[test]
1985    fn fails_without_writing_when_any_translation_fails() {
1986        let temp_dir = TempDir::new().unwrap();
1987        let source = temp_dir.path().join("en.strings");
1988        let target = temp_dir.path().join("fr.strings");
1989
1990        fs::write(
1991            &source,
1992            "\"welcome\" = \"Welcome\";\n\"bye\" = \"Goodbye\";\n",
1993        )
1994        .unwrap();
1995        fs::write(&target, "\"welcome\" = \"\";\n\"bye\" = \"\";\n").unwrap();
1996        let before = fs::read_to_string(&target).unwrap();
1997
1998        let prepared = prepare_translation(&base_options(&source, Some(&target))).unwrap();
1999        let err = run_prepared_translation(
2000            prepared,
2001            Some(Arc::new(MockBackend::new(vec![
2002                (("welcome", "fr"), Ok("Bienvenue".to_string())),
2003                (("bye", "fr"), Err("boom".to_string())),
2004            ]))),
2005        )
2006        .unwrap_err();
2007
2008        assert!(err.contains("no files were written"));
2009        let after = fs::read_to_string(&target).unwrap();
2010        assert_eq!(before, after);
2011    }
2012
2013    #[test]
2014    fn uses_config_defaults_when_flags_are_missing() {
2015        let temp_dir = TempDir::new().unwrap();
2016        let source = temp_dir.path().join("source.csv");
2017        let config = temp_dir.path().join("langcodec.toml");
2018        fs::write(&source, "key,en,fr\nwelcome,Welcome,\n").unwrap();
2019        fs::write(
2020            &config,
2021            r#"[openai]
2022model = "gpt-5.4"
2023
2024[translate]
2025source_lang = "en"
2026target_lang = ["fr"]
2027concurrency = 2
2028status = ["new", "stale"]
2029"#,
2030        )
2031        .unwrap();
2032
2033        let options = TranslateOptions {
2034            source: Some(source.to_string_lossy().to_string()),
2035            target: None,
2036            output: None,
2037            source_lang: None,
2038            target_langs: Vec::new(),
2039            status: None,
2040            provider: None,
2041            model: None,
2042            concurrency: None,
2043            config: Some(config.to_string_lossy().to_string()),
2044            use_tolgee: false,
2045            tolgee_config: None,
2046            tolgee_namespaces: Vec::new(),
2047            dry_run: true,
2048            strict: false,
2049            ui_mode: UiMode::Plain,
2050        };
2051
2052        let prepared = prepare_translation(&options).unwrap();
2053        assert_eq!(prepared.opts.model.as_deref(), Some("gpt-5.4"));
2054        assert_eq!(prepared.opts.target_langs, vec!["fr".to_string()]);
2055        assert_eq!(prepared.summary.queued, 1);
2056    }
2057
2058    #[test]
2059    fn uses_array_target_langs_from_config() {
2060        let temp_dir = TempDir::new().unwrap();
2061        let source = temp_dir.path().join("source.csv");
2062        let config = temp_dir.path().join("langcodec.toml");
2063        fs::write(&source, "key,en,fr,de\nwelcome,Welcome,,\n").unwrap();
2064        fs::write(
2065            &config,
2066            r#"[openai]
2067model = "gpt-5.4"
2068
2069[translate.input]
2070lang = "en"
2071
2072[translate.output]
2073lang = ["fr", "de"]
2074"#,
2075        )
2076        .unwrap();
2077
2078        let options = TranslateOptions {
2079            source: Some(source.to_string_lossy().to_string()),
2080            target: None,
2081            output: None,
2082            source_lang: None,
2083            target_langs: Vec::new(),
2084            status: None,
2085            provider: None,
2086            model: None,
2087            concurrency: None,
2088            config: Some(config.to_string_lossy().to_string()),
2089            use_tolgee: false,
2090            tolgee_config: None,
2091            tolgee_namespaces: Vec::new(),
2092            dry_run: true,
2093            strict: false,
2094            ui_mode: UiMode::Plain,
2095        };
2096
2097        let prepared = prepare_translation(&options).unwrap();
2098        assert_eq!(
2099            prepared.opts.target_langs,
2100            vec!["fr".to_string(), "de".to_string()]
2101        );
2102        assert_eq!(prepared.summary.queued, 2);
2103    }
2104
2105    #[test]
2106    fn uses_translated_output_status_from_config() {
2107        let temp_dir = TempDir::new().unwrap();
2108        let source = temp_dir.path().join("Localizable.xcstrings");
2109        let config = temp_dir.path().join("langcodec.toml");
2110        fs::write(
2111            &source,
2112            r#"{
2113  "sourceLanguage" : "en",
2114  "version" : "1.0",
2115  "strings" : {
2116    "welcome" : {
2117      "localizations" : {
2118        "en" : {
2119          "stringUnit" : {
2120            "state" : "new",
2121            "value" : "Welcome"
2122          }
2123        }
2124      }
2125    }
2126  }
2127}"#,
2128        )
2129        .unwrap();
2130        fs::write(
2131            &config,
2132            r#"[openai]
2133model = "gpt-5.4"
2134
2135[translate.input]
2136source = "Localizable.xcstrings"
2137lang = "en"
2138
2139[translate.output]
2140lang = ["fr"]
2141status = "translated"
2142"#,
2143        )
2144        .unwrap();
2145
2146        let options = TranslateOptions {
2147            source: None,
2148            target: None,
2149            output: None,
2150            source_lang: None,
2151            target_langs: Vec::new(),
2152            status: None,
2153            provider: None,
2154            model: None,
2155            concurrency: None,
2156            config: Some(config.to_string_lossy().to_string()),
2157            use_tolgee: false,
2158            tolgee_config: None,
2159            tolgee_namespaces: Vec::new(),
2160            dry_run: false,
2161            strict: false,
2162            ui_mode: UiMode::Plain,
2163        };
2164
2165        let runs = expand_translate_invocations(&options).unwrap();
2166        let prepared = prepare_translation(&runs[0]).unwrap();
2167        let output_path = prepared.output_path.clone();
2168        run_prepared_translation(
2169            prepared,
2170            Some(Arc::new(MockBackend::new(vec![(
2171                ("welcome", "fr"),
2172                Ok("Bienvenue".to_string()),
2173            )]))),
2174        )
2175        .unwrap();
2176
2177        let written = fs::read_to_string(output_path).unwrap();
2178        let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
2179        assert_eq!(
2180            parsed["strings"]["welcome"]["localizations"]["fr"]["stringUnit"]["state"],
2181            "translated"
2182        );
2183    }
2184
2185    #[test]
2186    fn rejects_invalid_output_status_from_config() {
2187        let temp_dir = TempDir::new().unwrap();
2188        let source = temp_dir.path().join("source.csv");
2189        let config = temp_dir.path().join("langcodec.toml");
2190        fs::write(&source, "key,en,fr\nwelcome,Welcome,\n").unwrap();
2191        fs::write(
2192            &config,
2193            r#"[openai]
2194model = "gpt-5.4"
2195
2196[translate.input]
2197lang = "en"
2198
2199[translate.output]
2200lang = ["fr"]
2201status = "new"
2202"#,
2203        )
2204        .unwrap();
2205
2206        let options = TranslateOptions {
2207            source: Some(source.to_string_lossy().to_string()),
2208            target: None,
2209            output: None,
2210            source_lang: None,
2211            target_langs: Vec::new(),
2212            status: None,
2213            provider: None,
2214            model: None,
2215            concurrency: None,
2216            config: Some(config.to_string_lossy().to_string()),
2217            use_tolgee: false,
2218            tolgee_config: None,
2219            tolgee_namespaces: Vec::new(),
2220            dry_run: true,
2221            strict: false,
2222            ui_mode: UiMode::Plain,
2223        };
2224
2225        let err = prepare_translation(&options).unwrap_err();
2226        assert!(err.contains("translate output status must be either"));
2227    }
2228
2229    #[test]
2230    fn expands_single_source_from_config_relative_to_config_file() {
2231        let temp_dir = TempDir::new().unwrap();
2232        let config_dir = temp_dir.path().join("project");
2233        fs::create_dir_all(config_dir.join("locales")).unwrap();
2234        fs::create_dir_all(config_dir.join("output")).unwrap();
2235        let config = config_dir.join("langcodec.toml");
2236        fs::write(
2237            &config,
2238            r#"[translate]
2239source = "locales/Localizable.xcstrings"
2240target = "output/Translated.xcstrings"
2241"#,
2242        )
2243        .unwrap();
2244
2245        let runs = expand_translate_invocations(&TranslateOptions {
2246            source: None,
2247            target: None,
2248            output: None,
2249            source_lang: None,
2250            target_langs: Vec::new(),
2251            status: None,
2252            provider: None,
2253            model: None,
2254            concurrency: None,
2255            config: Some(config.to_string_lossy().to_string()),
2256            use_tolgee: false,
2257            tolgee_config: None,
2258            tolgee_namespaces: Vec::new(),
2259            dry_run: true,
2260            strict: false,
2261            ui_mode: UiMode::Plain,
2262        })
2263        .unwrap();
2264
2265        assert_eq!(runs.len(), 1);
2266        assert_eq!(
2267            runs[0].source,
2268            Some(
2269                config_dir
2270                    .join("locales/Localizable.xcstrings")
2271                    .to_string_lossy()
2272                    .to_string()
2273            )
2274        );
2275        assert_eq!(
2276            runs[0].target,
2277            Some(
2278                config_dir
2279                    .join("output/Translated.xcstrings")
2280                    .to_string_lossy()
2281                    .to_string()
2282            )
2283        );
2284    }
2285
2286    #[test]
2287    fn expands_multiple_sources_from_config() {
2288        let temp_dir = TempDir::new().unwrap();
2289        let config_dir = temp_dir.path().join("project");
2290        fs::create_dir_all(&config_dir).unwrap();
2291        let config = config_dir.join("langcodec.toml");
2292        fs::write(
2293            &config,
2294            r#"[translate]
2295sources = ["one.xcstrings", "two.xcstrings"]
2296"#,
2297        )
2298        .unwrap();
2299
2300        let runs = expand_translate_invocations(&TranslateOptions {
2301            source: None,
2302            target: None,
2303            output: None,
2304            source_lang: None,
2305            target_langs: Vec::new(),
2306            status: None,
2307            provider: None,
2308            model: None,
2309            concurrency: None,
2310            config: Some(config.to_string_lossy().to_string()),
2311            use_tolgee: false,
2312            tolgee_config: None,
2313            tolgee_namespaces: Vec::new(),
2314            dry_run: true,
2315            strict: false,
2316            ui_mode: UiMode::Plain,
2317        })
2318        .unwrap();
2319
2320        assert_eq!(runs.len(), 2);
2321        assert_eq!(
2322            runs[0].source,
2323            Some(
2324                config_dir
2325                    .join("one.xcstrings")
2326                    .to_string_lossy()
2327                    .to_string()
2328            )
2329        );
2330        assert_eq!(
2331            runs[1].source,
2332            Some(
2333                config_dir
2334                    .join("two.xcstrings")
2335                    .to_string_lossy()
2336                    .to_string()
2337            )
2338        );
2339    }
2340
2341    #[test]
2342    fn expands_globbed_sources_from_config() {
2343        let temp_dir = TempDir::new().unwrap();
2344        let config_dir = temp_dir.path().join("project");
2345        let feature_a = config_dir.join("Modules").join("FeatureA");
2346        let feature_b = config_dir.join("Modules").join("FeatureB");
2347        fs::create_dir_all(&feature_a).unwrap();
2348        fs::create_dir_all(&feature_b).unwrap();
2349
2350        let first = feature_a.join("Localizable.xcstrings");
2351        let second = feature_b.join("Localizable.xcstrings");
2352        fs::write(
2353            &first,
2354            r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2355        )
2356        .unwrap();
2357        fs::write(
2358            &second,
2359            r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2360        )
2361        .unwrap();
2362
2363        let config = config_dir.join("langcodec.toml");
2364        fs::write(
2365            &config,
2366            r#"[translate.input]
2367sources = ["Modules/*/Localizable.xcstrings"]
2368"#,
2369        )
2370        .unwrap();
2371
2372        let runs = expand_translate_invocations(&TranslateOptions {
2373            source: None,
2374            target: None,
2375            output: None,
2376            source_lang: None,
2377            target_langs: Vec::new(),
2378            status: None,
2379            provider: None,
2380            model: None,
2381            concurrency: None,
2382            config: Some(config.to_string_lossy().to_string()),
2383            use_tolgee: false,
2384            tolgee_config: None,
2385            tolgee_namespaces: Vec::new(),
2386            dry_run: true,
2387            strict: false,
2388            ui_mode: UiMode::Plain,
2389        })
2390        .unwrap();
2391
2392        let mut sources = runs
2393            .into_iter()
2394            .map(|run| run.source.expect("source"))
2395            .collect::<Vec<_>>();
2396        sources.sort();
2397
2398        let mut expected = vec![
2399            first.to_string_lossy().to_string(),
2400            second.to_string_lossy().to_string(),
2401        ];
2402        expected.sort();
2403
2404        assert_eq!(sources, expected);
2405    }
2406
2407    #[test]
2408    fn rejects_target_with_multiple_sources_from_config() {
2409        let temp_dir = TempDir::new().unwrap();
2410        let config = temp_dir.path().join("langcodec.toml");
2411        fs::write(
2412            &config,
2413            r#"[translate]
2414sources = ["one.xcstrings", "two.xcstrings"]
2415target = "translated.xcstrings"
2416"#,
2417        )
2418        .unwrap();
2419
2420        let err = expand_translate_invocations(&TranslateOptions {
2421            source: None,
2422            target: None,
2423            output: None,
2424            source_lang: None,
2425            target_langs: Vec::new(),
2426            status: None,
2427            provider: None,
2428            model: None,
2429            concurrency: None,
2430            config: Some(config.to_string_lossy().to_string()),
2431            use_tolgee: false,
2432            tolgee_config: None,
2433            tolgee_namespaces: Vec::new(),
2434            dry_run: true,
2435            strict: false,
2436            ui_mode: UiMode::Plain,
2437        })
2438        .unwrap_err();
2439
2440        assert!(err.contains("translate.input.sources/translate.sources cannot be combined"));
2441    }
2442
2443    #[test]
2444    fn skips_plural_entries() {
2445        let temp_dir = TempDir::new().unwrap();
2446        let source = temp_dir.path().join("Localizable.xcstrings");
2447        let target = temp_dir.path().join("translated.xcstrings");
2448        fs::write(
2449            &source,
2450            r#"{
2451  "sourceLanguage" : "en",
2452  "version" : "1.0",
2453  "strings" : {
2454    "welcome" : {
2455      "localizations" : {
2456        "en" : {
2457          "stringUnit" : {
2458            "state" : "new",
2459            "value" : "Welcome"
2460          }
2461        }
2462      }
2463    },
2464    "item_count" : {
2465      "localizations" : {
2466        "en" : {
2467          "variations" : {
2468            "plural" : {
2469              "one" : {
2470                "stringUnit" : {
2471                  "state" : "new",
2472                  "value" : "%#@items@"
2473                }
2474              },
2475              "other" : {
2476                "stringUnit" : {
2477                  "state" : "new",
2478                  "value" : "%#@items@"
2479                }
2480              }
2481            }
2482          }
2483        }
2484      }
2485    }
2486  }
2487}"#,
2488        )
2489        .unwrap();
2490
2491        let prepared = prepare_translation(&base_options(&source, Some(&target))).unwrap();
2492        assert_eq!(prepared.summary.skipped_plural, 1);
2493        assert_eq!(prepared.summary.queued, 1);
2494    }
2495
2496    #[test]
2497    fn rejects_in_place_single_language_translation_without_target() {
2498        let temp_dir = TempDir::new().unwrap();
2499        let source = temp_dir.path().join("en.strings");
2500        fs::write(&source, "\"welcome\" = \"Welcome\";\n").unwrap();
2501
2502        let options = base_options(&source, None);
2503        let err = prepare_translation(&options).unwrap_err();
2504        assert!(err.contains("Omitting --target is only supported"));
2505    }
2506
2507    #[test]
2508    fn canonicalizes_target_language_from_existing_target_resource() {
2509        let temp_dir = TempDir::new().unwrap();
2510        let source = temp_dir.path().join("translations.csv");
2511        let target = temp_dir.path().join("target.csv");
2512        fs::write(&source, "key,en\nwelcome,Welcome\n").unwrap();
2513        fs::write(&target, "key,fr-CA\nwelcome,\n").unwrap();
2514
2515        let mut options = base_options(&source, Some(&target));
2516        options.target_langs = vec!["fr".to_string()];
2517        options.source_lang = Some("en".to_string());
2518
2519        let prepared = prepare_translation(&options).unwrap();
2520        assert_eq!(prepared.opts.target_langs, vec!["fr-CA".to_string()]);
2521        assert_eq!(prepared.summary.queued, 1);
2522    }
2523
2524    #[test]
2525    fn infers_status_from_target_input_format_not_output_format() {
2526        let temp_dir = TempDir::new().unwrap();
2527        let source = temp_dir.path().join("en.strings");
2528        let target = temp_dir.path().join("fr.strings");
2529        let output = temp_dir.path().join("translated.xcstrings");
2530
2531        fs::write(&source, "\"welcome\" = \"Welcome\";\n").unwrap();
2532        fs::write(&target, "\"welcome\" = \"\";\n").unwrap();
2533
2534        let mut options = base_options(&source, Some(&target));
2535        options.output = Some(output.to_string_lossy().to_string());
2536
2537        let prepared = prepare_translation(&options).unwrap();
2538        assert_eq!(prepared.summary.queued, 1);
2539    }
2540
2541    #[test]
2542    fn parses_fenced_json_translation() {
2543        let text = "```json\n{\"translation\":\"Bonjour\"}\n```";
2544        let parsed = parse_translation_response(text).unwrap();
2545        assert_eq!(parsed, "Bonjour");
2546    }
2547
2548    #[test]
2549    fn build_prompt_includes_comment_context() {
2550        let prompt = build_prompt(&BackendRequest {
2551            key: "countdown".to_string(),
2552            source_lang: "zh-Hans".to_string(),
2553            target_lang: "fr".to_string(),
2554            source_value: "代码过期倒计时".to_string(),
2555            source_comment: Some("A label displayed below the code expiration timer.".to_string()),
2556        });
2557
2558        assert!(prompt.contains("Comment:"));
2559        assert!(prompt.contains("A label displayed below the code expiration timer."));
2560    }
2561
2562    #[test]
2563    fn translates_multiple_target_languages_into_multilanguage_output() {
2564        let temp_dir = TempDir::new().unwrap();
2565        let source = temp_dir.path().join("Localizable.xcstrings");
2566        fs::write(
2567            &source,
2568            r#"{
2569  "sourceLanguage" : "en",
2570  "version" : "1.0",
2571  "strings" : {
2572    "welcome" : {
2573      "localizations" : {
2574        "en" : {
2575          "stringUnit" : {
2576            "state" : "new",
2577            "value" : "Welcome"
2578          }
2579        }
2580      }
2581    }
2582  }
2583}"#,
2584        )
2585        .unwrap();
2586
2587        let mut options = base_options(&source, None);
2588        options.target_langs = vec!["fr".to_string(), "de".to_string()];
2589
2590        let prepared = prepare_translation(&options).unwrap();
2591        let output_path = prepared.output_path.clone();
2592        assert_eq!(
2593            prepared.opts.target_langs,
2594            vec!["fr".to_string(), "de".to_string()]
2595        );
2596        assert_eq!(prepared.summary.total_entries, 2);
2597        assert_eq!(prepared.summary.queued, 2);
2598
2599        let outcome = run_prepared_translation(
2600            prepared,
2601            Some(Arc::new(MockBackend::new(vec![
2602                (("welcome", "fr"), Ok("Bienvenue".to_string())),
2603                (("welcome", "de"), Ok("Willkommen".to_string())),
2604            ]))),
2605        )
2606        .unwrap();
2607
2608        assert_eq!(outcome.translated, 2);
2609        let written = fs::read_to_string(output_path).unwrap();
2610        assert!(written.contains("\"fr\""));
2611        assert!(written.contains("\"Bienvenue\""));
2612        assert!(written.contains("\"de\""));
2613        assert!(written.contains("\"Willkommen\""));
2614    }
2615
2616    #[test]
2617    fn rejects_multiple_target_languages_for_single_language_output() {
2618        let temp_dir = TempDir::new().unwrap();
2619        let source = temp_dir.path().join("en.strings");
2620        let target = temp_dir.path().join("fr.strings");
2621        fs::write(&source, "\"welcome\" = \"Welcome\";\n").unwrap();
2622
2623        let mut options = base_options(&source, Some(&target));
2624        options.target_langs = vec!["fr".to_string(), "de".to_string()];
2625
2626        let err = prepare_translation(&options).unwrap_err();
2627        assert!(err.contains("Multiple --target-lang values are only supported"));
2628    }
2629
2630    #[test]
2631    fn preserves_catalog_source_language_when_translating_from_non_source_locale() {
2632        let temp_dir = TempDir::new().unwrap();
2633        let source = temp_dir.path().join("Localizable.xcstrings");
2634        fs::write(
2635            &source,
2636            r#"{
2637  "sourceLanguage" : "en",
2638  "version" : "1.0",
2639  "strings" : {
2640    "countdown" : {
2641      "comment" : "A label displayed below the code expiration timer.",
2642      "localizations" : {
2643        "en" : {
2644          "stringUnit" : {
2645            "state" : "translated",
2646            "value" : "Code expired countdown"
2647          }
2648        },
2649        "zh-Hans" : {
2650          "stringUnit" : {
2651            "state" : "translated",
2652            "value" : "代码过期倒计时"
2653          }
2654        }
2655      }
2656    }
2657  }
2658}"#,
2659        )
2660        .unwrap();
2661
2662        let mut options = base_options(&source, None);
2663        options.source_lang = Some("zh-Hans".to_string());
2664        options.target_langs = vec!["fr".to_string()];
2665
2666        let prepared = prepare_translation(&options).unwrap();
2667        let output_path = prepared.output_path.clone();
2668        let outcome = run_prepared_translation(
2669            prepared,
2670            Some(Arc::new(MockBackend::new(vec![(
2671                ("countdown", "fr"),
2672                Ok("Compte a rebours du code expire".to_string()),
2673            )]))),
2674        )
2675        .unwrap();
2676
2677        assert_eq!(outcome.translated, 1);
2678        let written = fs::read_to_string(output_path).unwrap();
2679        let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
2680        assert_eq!(parsed["sourceLanguage"], "en");
2681        assert_eq!(
2682            parsed["strings"]["countdown"]["localizations"]["fr"]["stringUnit"]["value"],
2683            "Compte a rebours du code expire"
2684        );
2685    }
2686
2687    #[test]
2688    fn fails_preflight_before_translation_when_output_cannot_serialize() {
2689        let temp_dir = TempDir::new().unwrap();
2690        let source = temp_dir.path().join("Localizable.xcstrings");
2691        fs::write(
2692            &source,
2693            r#"{
2694  "sourceLanguage" : "en",
2695  "version" : "1.0",
2696  "strings" : {
2697    "welcome" : {
2698      "localizations" : {
2699        "en" : {
2700          "stringUnit" : {
2701            "state" : "translated",
2702            "value" : "Welcome"
2703          }
2704        }
2705      }
2706    }
2707  }
2708}"#,
2709        )
2710        .unwrap();
2711
2712        let prepared = prepare_translation(&base_options(&source, None)).unwrap();
2713        let mut broken = prepared.clone();
2714        broken
2715            .target_codec
2716            .get_mut_by_language("fr")
2717            .unwrap()
2718            .metadata
2719            .custom
2720            .insert("source_language".to_string(), "zh-Hans".to_string());
2721
2722        let err = run_prepared_translation(
2723            broken,
2724            Some(Arc::new(MockBackend::new(vec![(
2725                ("welcome", "fr"),
2726                Ok("Bonjour".to_string()),
2727            )]))),
2728        )
2729        .unwrap_err();
2730        assert!(err.contains("Preflight output validation failed"));
2731        assert!(err.contains("Source language mismatch"));
2732    }
2733
2734    #[test]
2735    fn tolgee_prefill_uses_ai_fallback_and_pushes_namespace() {
2736        let temp_dir = TempDir::new().unwrap();
2737        let project_root = temp_dir.path();
2738        let source = project_root.join("Localizable.xcstrings");
2739        let payload = project_root.join("pull_payload.xcstrings");
2740        let capture = project_root.join("captured_config.json");
2741        let log = project_root.join("tolgee.log");
2742
2743        write_translate_source_catalog(&source);
2744        write_translate_tolgee_payload(&payload);
2745        let tolgee_config = write_translate_tolgee_config(project_root);
2746        write_fake_tolgee(project_root, &payload, &capture, &log);
2747
2748        let mut options = base_options(&source, None);
2749        options.target_langs = vec!["fr".to_string()];
2750        options.provider = Some("openai".to_string());
2751        options.model = Some("gpt-4.1-mini".to_string());
2752        options.use_tolgee = true;
2753        options.tolgee_config = Some(tolgee_config.to_string_lossy().to_string());
2754        options.tolgee_namespaces = vec!["Core".to_string()];
2755
2756        let prepared = prepare_translation(&options).unwrap();
2757        assert_eq!(prepared.jobs.len(), 1);
2758        assert_eq!(prepared.jobs[0].key, "bye");
2759
2760        let outcome = run_prepared_translation(
2761            prepared,
2762            Some(Arc::new(MockBackend::new(vec![(
2763                ("bye", "fr"),
2764                Ok("Au revoir".to_string()),
2765            )]))),
2766        )
2767        .unwrap();
2768
2769        assert_eq!(outcome.translated, 1);
2770        let written = fs::read_to_string(&source).unwrap();
2771        assert!(written.contains("\"Bienvenue\""));
2772        assert!(written.contains("\"Au revoir\""));
2773
2774        let log_contents = fs::read_to_string(&log).unwrap();
2775        assert!(log_contents.contains("pull|"));
2776        assert!(log_contents.contains("push|"));
2777
2778        let captured = fs::read_to_string(&capture).unwrap();
2779        assert!(captured.contains("\"namespaces\""));
2780        assert!(captured.contains("\"Core\""));
2781    }
2782
2783    #[test]
2784    fn tolgee_translate_ignores_unmapped_catalogs_without_namespace_filter() {
2785        let temp_dir = TempDir::new().unwrap();
2786        let project_root = temp_dir.path();
2787        let source = project_root.join("ModuleExport.xcstrings");
2788
2789        fs::write(
2790            &source,
2791            r#"{
2792  "sourceLanguage" : "en",
2793  "version" : "1.0",
2794  "strings" : {
2795    "welcome" : {
2796      "localizations" : {
2797        "en" : {
2798          "stringUnit" : {
2799            "state" : "translated",
2800            "value" : "Welcome"
2801          }
2802        },
2803        "fr" : {
2804          "stringUnit" : {
2805            "state" : "translated",
2806            "value" : "Bienvenue"
2807          }
2808        }
2809      }
2810    }
2811  }
2812}"#,
2813        )
2814        .unwrap();
2815
2816        let tolgee_config = write_translate_tolgee_config(project_root);
2817        let mut options = base_options(&source, None);
2818        options.target_langs = vec!["fr".to_string()];
2819        options.provider = None;
2820        options.model = None;
2821        options.use_tolgee = true;
2822        options.tolgee_config = Some(tolgee_config.to_string_lossy().to_string());
2823
2824        let prepared = prepare_translation(&options).unwrap();
2825        assert!(prepared.tolgee_context.is_none());
2826        assert!(prepared.jobs.is_empty());
2827
2828        let outcome = run_prepared_translation(prepared, None).unwrap();
2829        assert_eq!(outcome.translated, 0);
2830        assert_eq!(outcome.failed, 0);
2831    }
2832
2833    #[test]
2834    fn falls_back_to_xcstrings_key_when_source_locale_entry_is_missing() {
2835        let temp_dir = TempDir::new().unwrap();
2836        let source = temp_dir.path().join("Localizable.xcstrings");
2837        fs::write(
2838            &source,
2839            r#"{
2840  "sourceLanguage" : "en",
2841  "version" : "1.0",
2842  "strings" : {
2843    "99+ users have won tons of blue diamonds here" : {
2844      "localizations" : {
2845        "tr" : {
2846          "stringUnit" : {
2847            "state" : "translated",
2848            "value" : "99+ kullanici burada tonlarca mavi elmas kazandi"
2849          }
2850        }
2851      }
2852    }
2853  }
2854}"#,
2855        )
2856        .unwrap();
2857
2858        let mut options = base_options(&source, None);
2859        options.source_lang = Some("en".to_string());
2860        options.target_langs = vec!["zh-Hans".to_string()];
2861
2862        let prepared = prepare_translation(&options).unwrap();
2863        assert_eq!(prepared.summary.queued, 1);
2864        assert_eq!(
2865            prepared.jobs[0].source_value,
2866            "99+ users have won tons of blue diamonds here"
2867        );
2868    }
2869}