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