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