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}