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