1use crate::{
2 ai::{ProviderKind, read_api_key, resolve_model, resolve_provider},
3 config::{LoadedConfig, load_config, resolve_config_relative_path},
4 path_glob,
5 tui::{
6 DashboardEvent, DashboardInit, DashboardItem, DashboardItemStatus, DashboardKind,
7 DashboardLogTone, PlainReporter, ResolvedUiMode, RunReporter, SummaryRow, TuiReporter,
8 UiMode, resolve_ui_mode_for_current_terminal,
9 },
10 validation::validate_language_code,
11};
12use async_trait::async_trait;
13use langcodec::{
14 Codec, Entry, FormatType, ReadOptions, Resource, Translation,
15 formats::{AndroidStringsFormat, StringsFormat, XcstringsFormat},
16 infer_format_from_extension, infer_language_from_path,
17 traits::Parser,
18};
19use mentra::{
20 AgentConfig, ContentBlock, ModelInfo, Runtime,
21 agent::{AgentEvent, ToolProfile, WorkspaceConfig},
22 provider::{ProviderRequestOptions, ResponsesRequestOptions},
23 runtime::RunOptions,
24};
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27use std::{
28 collections::{BTreeMap, HashMap, VecDeque},
29 fs,
30 path::{Path, PathBuf},
31 sync::Arc,
32};
33use tokio::{
34 runtime::Builder,
35 sync::{Mutex as AsyncMutex, broadcast, mpsc},
36 task::JoinSet,
37};
38
39const DEFAULT_CONCURRENCY: usize = 4;
40const DEFAULT_TOOL_BUDGET: usize = 16;
41const GENERATED_COMMENT_MARKER: &str = "langcodec:auto-generated";
42const ANNOTATION_SYSTEM_PROMPT: &str = "You write translator-facing comments for application localization entries. Use the files tool or shell tool when needed to inspect source code. Prefer shell commands like rg for fast code search, then read the most relevant files before drafting. Prefer a short, concrete explanation of where or how the text is used so a translator can choose the right wording. If you are uncertain, say what the UI usage appears to be instead of inventing product meaning. Return JSON only with the shape {\"comment\":\"...\",\"confidence\":\"high|medium|low\"}.";
43
44#[derive(Debug, Clone)]
45pub struct AnnotateOptions {
46 pub input: Option<String>,
47 pub source_roots: Vec<String>,
48 pub output: Option<String>,
49 pub source_lang: Option<String>,
50 pub provider: Option<String>,
51 pub model: Option<String>,
52 pub concurrency: Option<usize>,
53 pub config: Option<String>,
54 pub dry_run: bool,
55 pub check: bool,
56 pub ui_mode: UiMode,
57}
58
59#[derive(Debug, Clone)]
60struct ResolvedAnnotateOptions {
61 input: String,
62 output: String,
63 source_roots: Vec<String>,
64 source_lang: Option<String>,
65 provider: ProviderKind,
66 model: String,
67 concurrency: usize,
68 dry_run: bool,
69 check: bool,
70 workspace_root: PathBuf,
71 ui_mode: ResolvedUiMode,
72}
73
74#[derive(Debug, Clone)]
75struct AnnotationRequest {
76 key: String,
77 source_lang: String,
78 source_value: String,
79 existing_comment: Option<String>,
80 source_roots: Vec<String>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
84struct AnnotationResponse {
85 comment: String,
86 confidence: String,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90enum AnnotationFormat {
91 Xcstrings,
92 Strings,
93 AndroidStrings,
94}
95
96impl AnnotationFormat {
97 fn to_format_type(self) -> FormatType {
98 match self {
99 Self::Xcstrings => FormatType::Xcstrings,
100 Self::Strings => FormatType::Strings(None),
101 Self::AndroidStrings => FormatType::AndroidStrings(None),
102 }
103 }
104}
105
106#[derive(Debug, Clone)]
107struct AnnotationTarget {
108 key: String,
109 existing_comment: Option<String>,
110}
111
112enum WorkerUpdate {
113 Started {
114 worker_id: usize,
115 key: String,
116 candidate_count: usize,
117 top_candidate: Option<String>,
118 },
119 ToolCall {
120 tone: DashboardLogTone,
121 message: String,
122 },
123 Finished {
124 worker_id: usize,
125 key: String,
126 result: Result<Option<AnnotationResponse>, String>,
127 },
128}
129
130#[async_trait]
131trait AnnotationBackend: Send + Sync {
132 async fn annotate(
133 &self,
134 request: AnnotationRequest,
135 event_tx: Option<mpsc::UnboundedSender<WorkerUpdate>>,
136 ) -> Result<Option<AnnotationResponse>, String>;
137}
138
139struct MentraAnnotatorBackend {
140 runtime: Arc<Runtime>,
141 model: ModelInfo,
142 workspace_root: PathBuf,
143}
144
145impl MentraAnnotatorBackend {
146 fn new(opts: &ResolvedAnnotateOptions) -> Result<Self, String> {
147 let api_key = read_api_key(&opts.provider)?;
148 let provider = opts.provider.builtin_provider();
149 let runtime = Runtime::builder()
150 .with_provider(provider, api_key)
151 .build()
152 .map_err(|e| format!("Failed to build Mentra runtime: {}", e))?;
153
154 Ok(Self {
155 runtime: Arc::new(runtime),
156 model: ModelInfo::new(opts.model.clone(), provider),
157 workspace_root: opts.workspace_root.clone(),
158 })
159 }
160
161 #[cfg(test)]
162 fn from_runtime(runtime: Runtime, model: ModelInfo, workspace_root: PathBuf) -> Self {
163 Self {
164 runtime: Arc::new(runtime),
165 model,
166 workspace_root,
167 }
168 }
169}
170
171#[async_trait]
172impl AnnotationBackend for MentraAnnotatorBackend {
173 async fn annotate(
174 &self,
175 request: AnnotationRequest,
176 event_tx: Option<mpsc::UnboundedSender<WorkerUpdate>>,
177 ) -> Result<Option<AnnotationResponse>, String> {
178 let config = build_agent_config(&self.workspace_root);
179 let mut agent = self
180 .runtime
181 .spawn_with_config("annotate", self.model.clone(), config)
182 .map_err(|e| format!("Failed to spawn Mentra agent: {}", e))?;
183 let tool_logger =
184 spawn_tool_call_logger(agent.subscribe_events(), request.key.clone(), event_tx);
185
186 let response = agent
187 .run(
188 vec![ContentBlock::text(build_annotation_prompt(&request))],
189 RunOptions {
190 tool_budget: Some(DEFAULT_TOOL_BUDGET),
191 ..RunOptions::default()
192 },
193 )
194 .await;
195 tool_logger.abort();
196 let _ = tool_logger.await;
197
198 let response = response.map_err(|e| format!("Annotation agent failed: {}", e))?;
199
200 parse_annotation_response(&response.text()).map(Some)
201 }
202}
203
204pub fn run_annotate_command(opts: AnnotateOptions) -> Result<(), String> {
205 let config = load_config(opts.config.as_deref())?;
206 let runs = expand_annotate_invocations(&opts, config.as_ref())?;
207
208 for resolved in runs {
209 let backend: Arc<dyn AnnotationBackend> = Arc::new(MentraAnnotatorBackend::new(&resolved)?);
210 run_annotate_with_backend(resolved, backend)?;
211 }
212
213 Ok(())
214}
215
216fn run_annotate_with_backend(
217 opts: ResolvedAnnotateOptions,
218 backend: Arc<dyn AnnotationBackend>,
219) -> Result<(), String> {
220 let annotation_format = annotation_format_from_path(&opts.input)?;
221 let mut codec = read_annotation_codec(&opts.input, annotation_format)?;
222 let source_lang = opts
223 .source_lang
224 .clone()
225 .or_else(|| default_source_language(&codec))
226 .ok_or_else(|| {
227 format!(
228 "Could not infer source language for '{}'; pass --source-lang",
229 opts.input
230 )
231 })?;
232 validate_language_code(&source_lang)?;
233
234 let source_values = source_value_map(&codec.resources, &source_lang);
235 let requests = build_annotation_requests(
236 &codec,
237 annotation_format,
238 &source_lang,
239 &source_values,
240 &opts.source_roots,
241 &opts.workspace_root,
242 );
243
244 if requests.is_empty() {
245 println!("No entries require annotation updates.");
246 return Ok(());
247 }
248
249 let mut reporter = create_annotate_reporter(&opts, &source_lang, &requests)?;
250 reporter.emit(DashboardEvent::Log {
251 tone: DashboardLogTone::Info,
252 message: format!("Annotating {}", opts.input),
253 });
254 reporter.emit(DashboardEvent::Log {
255 tone: DashboardLogTone::Info,
256 message: format!(
257 "Generating translator comments for {} entr{} with {} worker(s)...",
258 requests.len(),
259 if requests.len() == 1 { "y" } else { "ies" },
260 opts.concurrency
261 ),
262 });
263 let results = annotate_requests(requests.clone(), backend, opts.concurrency, &mut *reporter);
264 let results = results?;
265 let mut changed = 0usize;
266 let mut unmatched = 0usize;
267
268 for request in &requests {
269 match results.get(&request.key) {
270 Some(Some(annotation))
271 if apply_annotation(
272 &mut codec,
273 annotation_format,
274 &request.key,
275 &annotation.comment,
276 )? =>
277 {
278 changed += 1;
279 }
280 Some(Some(_)) => {}
281 Some(None) => unmatched += 1,
282 None => {}
283 }
284 }
285
286 if opts.check && changed > 0 {
287 reporter.emit(DashboardEvent::Log {
288 tone: DashboardLogTone::Warning,
289 message: format!("would change: {}", opts.output),
290 });
291 reporter.finish()?;
292 println!("would change: {}", opts.output);
293 return Err(format!("would change: {}", opts.output));
294 }
295
296 if opts.dry_run {
297 reporter.emit(DashboardEvent::Log {
298 tone: DashboardLogTone::Info,
299 message: format!(
300 "DRY-RUN: would update {} comment(s) in {}",
301 changed, opts.output
302 ),
303 });
304 reporter.finish()?;
305 println!(
306 "DRY-RUN: would update {} comment(s) in {}",
307 changed, opts.output
308 );
309 if unmatched > 0 {
310 println!("Skipped {} entry(s) without generated comments", unmatched);
311 }
312 return Ok(());
313 }
314
315 if changed == 0 {
316 reporter.emit(DashboardEvent::Log {
317 tone: DashboardLogTone::Success,
318 message: "No comment updates were necessary.".to_string(),
319 });
320 reporter.finish()?;
321 println!("No comment updates were necessary.");
322 if unmatched > 0 {
323 println!("Skipped {} entry(s) without generated comments", unmatched);
324 }
325 return Ok(());
326 }
327
328 reporter.emit(DashboardEvent::Log {
329 tone: DashboardLogTone::Info,
330 message: format!("Writing {}", opts.output),
331 });
332 if let Err(err) = write_annotated_codec(&codec, annotation_format, &opts.output) {
333 let err = format!("Failed to write '{}': {}", opts.output, err);
334 reporter.emit(DashboardEvent::Log {
335 tone: DashboardLogTone::Error,
336 message: err.clone(),
337 });
338 reporter.finish()?;
339 return Err(err);
340 }
341 reporter.emit(DashboardEvent::Log {
342 tone: DashboardLogTone::Success,
343 message: format!("Updated {} comment(s) in {}", changed, opts.output),
344 });
345 reporter.finish()?;
346
347 println!("Updated {} comment(s) in {}", changed, opts.output);
348 if unmatched > 0 {
349 println!("Skipped {} entry(s) without generated comments", unmatched);
350 }
351 Ok(())
352}
353
354fn expand_annotate_invocations(
355 opts: &AnnotateOptions,
356 config: Option<&LoadedConfig>,
357) -> Result<Vec<ResolvedAnnotateOptions>, String> {
358 let cfg = config.map(|item| &item.data.annotate);
359 let config_dir = config.and_then(LoadedConfig::config_dir);
360
361 if cfg
362 .and_then(|item| item.input.as_ref())
363 .is_some_and(|_| cfg.and_then(|item| item.inputs.as_ref()).is_some())
364 {
365 return Err("Config annotate.input and annotate.inputs cannot both be set".to_string());
366 }
367
368 let inputs = resolve_config_inputs(opts, cfg, config_dir)?;
369 if inputs.is_empty() {
370 return Err(
371 "--input is required unless annotate.input or annotate.inputs is set in langcodec.toml"
372 .to_string(),
373 );
374 }
375
376 let output = if let Some(output) = &opts.output {
377 Some(output.clone())
378 } else {
379 cfg.and_then(|item| item.output.clone())
380 .map(|path| resolve_config_relative_path(config_dir, &path))
381 };
382
383 if inputs.len() > 1 && output.is_some() {
384 return Err(
385 "annotate.inputs cannot be combined with annotate.output or CLI --output; use in-place annotation for multiple inputs"
386 .to_string(),
387 );
388 }
389
390 inputs
391 .into_iter()
392 .map(|input| {
393 resolve_annotate_options(
394 &AnnotateOptions {
395 input: Some(input),
396 source_roots: opts.source_roots.clone(),
397 output: output.clone(),
398 source_lang: opts.source_lang.clone(),
399 provider: opts.provider.clone(),
400 model: opts.model.clone(),
401 concurrency: opts.concurrency,
402 config: opts.config.clone(),
403 dry_run: opts.dry_run,
404 check: opts.check,
405 ui_mode: opts.ui_mode,
406 },
407 config,
408 )
409 })
410 .collect()
411}
412
413fn resolve_config_inputs(
414 opts: &AnnotateOptions,
415 cfg: Option<&crate::config::AnnotateConfig>,
416 config_dir: Option<&Path>,
417) -> Result<Vec<String>, String> {
418 fn has_glob_meta(path: &str) -> bool {
419 path.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
420 }
421
422 if let Some(input) = &opts.input {
423 return Ok(vec![input.clone()]);
424 }
425
426 if let Some(input) = cfg.and_then(|item| item.input.as_ref()) {
427 let resolved = vec![resolve_config_relative_path(config_dir, input)];
428 return if resolved.iter().any(|path| has_glob_meta(path)) {
429 path_glob::expand_input_globs(&resolved)
430 } else {
431 Ok(resolved)
432 };
433 }
434
435 if let Some(inputs) = cfg.and_then(|item| item.inputs.as_ref()) {
436 let resolved = inputs
437 .iter()
438 .map(|input| resolve_config_relative_path(config_dir, input))
439 .collect::<Vec<_>>();
440 return if resolved.iter().any(|path| has_glob_meta(path)) {
441 path_glob::expand_input_globs(&resolved)
442 } else {
443 Ok(resolved)
444 };
445 }
446
447 Ok(Vec::new())
448}
449
450fn resolve_annotate_options(
451 opts: &AnnotateOptions,
452 config: Option<&LoadedConfig>,
453) -> Result<ResolvedAnnotateOptions, String> {
454 let cfg = config.map(|item| &item.data.annotate);
455 let config_dir = config.and_then(LoadedConfig::config_dir);
456 let cwd = std::env::current_dir()
457 .map_err(|e| format!("Failed to determine current directory: {}", e))?;
458
459 let input = if let Some(input) = &opts.input {
460 absolutize_path(input, &cwd)
461 } else if let Some(input) = cfg.and_then(|item| item.input.as_deref()) {
462 absolutize_path(&resolve_config_relative_path(config_dir, input), &cwd)
463 } else {
464 return Err(
465 "--input is required unless annotate.input or annotate.inputs is set in langcodec.toml"
466 .to_string(),
467 );
468 };
469
470 let source_roots = if !opts.source_roots.is_empty() {
471 opts.source_roots
472 .iter()
473 .map(|path| absolutize_path(path, &cwd))
474 .collect::<Vec<_>>()
475 } else if let Some(roots) = cfg.and_then(|item| item.source_roots.as_ref()) {
476 roots
477 .iter()
478 .map(|path| absolutize_path(&resolve_config_relative_path(config_dir, path), &cwd))
479 .collect::<Vec<_>>()
480 } else {
481 Vec::new()
482 };
483 if source_roots.is_empty() {
484 return Err(
485 "--source-root is required unless annotate.source_roots is set in langcodec.toml"
486 .to_string(),
487 );
488 }
489 for root in &source_roots {
490 let path = Path::new(root);
491 if !path.is_dir() {
492 return Err(format!(
493 "Source root does not exist or is not a directory: {}",
494 root
495 ));
496 }
497 }
498
499 let output = if let Some(output) = &opts.output {
500 absolutize_path(output, &cwd)
501 } else if let Some(output) = cfg.and_then(|item| item.output.as_deref()) {
502 absolutize_path(&resolve_config_relative_path(config_dir, output), &cwd)
503 } else {
504 input.clone()
505 };
506 validate_annotate_paths(&input, &output)?;
507
508 let concurrency = opts
509 .concurrency
510 .or_else(|| cfg.and_then(|item| item.concurrency))
511 .unwrap_or(DEFAULT_CONCURRENCY);
512 if concurrency == 0 {
513 return Err("Concurrency must be greater than zero".to_string());
514 }
515
516 let provider = resolve_provider(
517 opts.provider.as_deref(),
518 config.map(|item| &item.data),
519 None,
520 )?;
521 let model = resolve_model(
522 opts.model.as_deref(),
523 config.map(|item| &item.data),
524 &provider,
525 None,
526 )?;
527
528 let source_lang = opts
529 .source_lang
530 .clone()
531 .or_else(|| cfg.and_then(|item| item.source_lang.clone()));
532 if let Some(lang) = &source_lang {
533 validate_language_code(lang)?;
534 }
535 let ui_mode = resolve_ui_mode_for_current_terminal(opts.ui_mode)?;
536
537 let workspace_root = derive_workspace_root(&input, &source_roots, &cwd);
538
539 Ok(ResolvedAnnotateOptions {
540 input,
541 output,
542 source_roots,
543 source_lang,
544 provider,
545 model,
546 concurrency,
547 dry_run: opts.dry_run,
548 check: opts.check,
549 workspace_root,
550 ui_mode,
551 })
552}
553
554fn validate_annotate_paths(input: &str, output: &str) -> Result<(), String> {
555 let input_format = annotation_format_from_path(input)?;
556 let output_format = annotation_format_from_path(output)?;
557 if input_format != output_format {
558 return Err(format!(
559 "Annotate output format must match input format (input='{}', output='{}')",
560 input, output
561 ));
562 }
563 Ok(())
564}
565
566fn annotation_format_from_path(path: &str) -> Result<AnnotationFormat, String> {
567 match infer_format_from_extension(path)
568 .ok_or_else(|| format!("Cannot infer annotate format from path: {}", path))?
569 {
570 FormatType::Xcstrings => Ok(AnnotationFormat::Xcstrings),
571 FormatType::Strings(_) => Ok(AnnotationFormat::Strings),
572 FormatType::AndroidStrings(_) => Ok(AnnotationFormat::AndroidStrings),
573 _ => Err(format!(
574 "annotate supports only .xcstrings, .strings, and Android strings.xml files, got '{}'",
575 path
576 )),
577 }
578}
579
580fn read_annotation_codec(path: &str, format: AnnotationFormat) -> Result<Codec, String> {
581 let format_type = format.to_format_type();
582 let language_hint = infer_language_from_path(path, &format_type).ok().flatten();
583 let mut codec = Codec::new();
584 codec
585 .read_file_by_extension_with_options(
586 path,
587 &ReadOptions::new().with_language_hint(language_hint),
588 )
589 .map_err(|e| format!("Failed to read '{}': {}", path, e))?;
590 Ok(codec)
591}
592
593fn default_source_language(codec: &Codec) -> Option<String> {
594 codec
595 .resources
596 .iter()
597 .find_map(|resource| resource.metadata.custom.get("source_language").cloned())
598 .or_else(|| {
599 (codec.resources.len() == 1)
600 .then(|| codec.resources[0].metadata.language.trim().to_string())
601 .filter(|lang| !lang.is_empty())
602 })
603}
604
605fn annotate_requests(
606 requests: Vec<AnnotationRequest>,
607 backend: Arc<dyn AnnotationBackend>,
608 concurrency: usize,
609 reporter: &mut dyn RunReporter,
610) -> Result<BTreeMap<String, Option<AnnotationResponse>>, String> {
611 let runtime = Builder::new_multi_thread()
612 .enable_all()
613 .build()
614 .map_err(|e| format!("Failed to start async runtime: {}", e))?;
615
616 let total = requests.len();
617 runtime.block_on(async {
618 let worker_count = concurrency.min(total).max(1);
619 let queue = Arc::new(AsyncMutex::new(VecDeque::from(requests)));
620 let (tx, mut rx) = mpsc::unbounded_channel::<WorkerUpdate>();
621 let mut set = JoinSet::new();
622 for worker_id in 1..=worker_count {
623 let backend = Arc::clone(&backend);
624 let queue = Arc::clone(&queue);
625 let tx = tx.clone();
626 set.spawn(async move {
627 loop {
628 let request = {
629 let mut queue = queue.lock().await;
630 queue.pop_front()
631 };
632
633 let Some(request) = request else {
634 break;
635 };
636
637 let key = request.key.clone();
638 let _ = tx.send(WorkerUpdate::Started {
639 worker_id,
640 key: key.clone(),
641 candidate_count: 0,
642 top_candidate: None,
643 });
644 let result = backend.annotate(request, Some(tx.clone())).await;
645 let _ = tx.send(WorkerUpdate::Finished {
646 worker_id,
647 key,
648 result,
649 });
650 }
651
652 Ok::<(), String>(())
653 });
654 }
655 drop(tx);
656
657 let mut results = BTreeMap::new();
658 let mut generated = 0usize;
659 let mut unmatched = 0usize;
660 let mut first_error = None;
661
662 while let Some(update) = rx.recv().await {
663 match update {
664 WorkerUpdate::Started {
665 worker_id,
666 key,
667 candidate_count,
668 top_candidate,
669 } => {
670 reporter.emit(DashboardEvent::Log {
671 tone: DashboardLogTone::Info,
672 message: annotate_worker_started_message(
673 worker_id,
674 &key,
675 candidate_count,
676 top_candidate.as_deref(),
677 ),
678 });
679 reporter.emit(DashboardEvent::UpdateItem {
680 id: key,
681 status: Some(DashboardItemStatus::Running),
682 subtitle: None,
683 source_text: None,
684 output_text: None,
685 note_text: None,
686 error_text: None,
687 extra_rows: None,
688 });
689 }
690 WorkerUpdate::ToolCall { tone, message } => {
691 reporter.emit(DashboardEvent::Log { tone, message });
692 }
693 WorkerUpdate::Finished {
694 worker_id,
695 key,
696 result,
697 } => {
698 match result {
699 Ok(annotation) => {
700 if annotation.is_some() {
701 generated += 1;
702 } else {
703 unmatched += 1;
704 }
705 let status = if annotation.is_some() {
706 DashboardItemStatus::Succeeded
707 } else {
708 DashboardItemStatus::Skipped
709 };
710 reporter.emit(DashboardEvent::Log {
711 tone: if annotation.is_some() {
712 DashboardLogTone::Success
713 } else {
714 DashboardLogTone::Warning
715 },
716 message: annotate_worker_finished_message(
717 worker_id,
718 &key,
719 &annotation,
720 ),
721 });
722 reporter.emit(DashboardEvent::UpdateItem {
723 id: key.clone(),
724 status: Some(status),
725 subtitle: None,
726 source_text: None,
727 output_text: annotation.as_ref().map(|item| item.comment.clone()),
728 note_text: None,
729 error_text: None,
730 extra_rows: annotation.as_ref().map(|item| {
731 vec![SummaryRow::new("Confidence", item.confidence.clone())]
732 }),
733 });
734 results.insert(key, annotation);
735 }
736 Err(err) => {
737 reporter.emit(DashboardEvent::Log {
738 tone: DashboardLogTone::Error,
739 message: format!(
740 "Worker {} finished key={} result=failed",
741 worker_id, key
742 ),
743 });
744 reporter.emit(DashboardEvent::UpdateItem {
745 id: key,
746 status: Some(DashboardItemStatus::Failed),
747 subtitle: None,
748 source_text: None,
749 output_text: None,
750 note_text: None,
751 error_text: Some(err.clone()),
752 extra_rows: None,
753 });
754 if first_error.is_none() {
755 first_error = Some(err);
756 }
757 }
758 }
759 reporter.emit(DashboardEvent::SummaryRows {
760 rows: annotate_summary_rows(total, generated, unmatched),
761 });
762 }
763 }
764 }
765
766 while let Some(joined) = set.join_next().await {
767 match joined {
768 Ok(Ok(())) => {}
769 Ok(Err(err)) => {
770 if first_error.is_none() {
771 first_error = Some(err);
772 }
773 }
774 Err(err) => {
775 if first_error.is_none() {
776 first_error = Some(format!("Annotation task failed: {}", err));
777 }
778 }
779 }
780 }
781
782 if let Some(err) = first_error {
783 return Err(err);
784 }
785
786 Ok(results)
787 })
788}
789
790fn build_annotation_requests(
791 codec: &Codec,
792 annotation_format: AnnotationFormat,
793 source_lang: &str,
794 source_values: &HashMap<String, String>,
795 source_roots: &[String],
796 workspace_root: &Path,
797) -> Vec<AnnotationRequest> {
798 let mut requests = Vec::new();
799 for target in collect_annotation_targets(codec, annotation_format) {
800 let source_value = source_values
801 .get(&target.key)
802 .cloned()
803 .unwrap_or_else(|| target.key.clone());
804
805 requests.push(AnnotationRequest {
806 key: target.key,
807 source_lang: source_lang.to_string(),
808 source_value,
809 existing_comment: target.existing_comment,
810 source_roots: source_roots
811 .iter()
812 .map(|root| display_path(workspace_root, Path::new(root)))
813 .collect(),
814 });
815 }
816
817 requests
818}
819
820fn collect_annotation_targets(
821 codec: &Codec,
822 annotation_format: AnnotationFormat,
823) -> Vec<AnnotationTarget> {
824 let mut targets = BTreeMap::<String, AnnotationTarget>::new();
825 let mut preserve_manual = BTreeMap::<String, bool>::new();
826
827 for resource in &codec.resources {
828 for entry in &resource.entries {
829 let key = entry.id.clone();
830 let target = targets
831 .entry(key.clone())
832 .or_insert_with(|| AnnotationTarget {
833 key: key.clone(),
834 existing_comment: None,
835 });
836
837 if target.existing_comment.is_none() {
838 target.existing_comment = display_comment(annotation_format, entry);
839 }
840
841 if should_preserve_manual_comment(annotation_format, entry) {
842 preserve_manual.insert(key, true);
843 }
844 }
845 }
846
847 targets
848 .into_iter()
849 .filter_map(|(key, target)| {
850 (!preserve_manual.get(&key).copied().unwrap_or(false)).then_some(target)
851 })
852 .collect()
853}
854
855fn should_preserve_manual_comment(annotation_format: AnnotationFormat, entry: &Entry) -> bool {
856 let Some(raw_comment) = entry.comment.as_deref() else {
857 return false;
858 };
859
860 match annotation_format {
861 AnnotationFormat::Xcstrings => !entry
862 .custom
863 .get("is_comment_auto_generated")
864 .and_then(|value| value.parse::<bool>().ok())
865 .unwrap_or(false),
866 AnnotationFormat::Strings | AnnotationFormat::AndroidStrings => {
867 !is_generated_inline_comment(annotation_format, raw_comment)
868 }
869 }
870}
871
872fn display_comment(annotation_format: AnnotationFormat, entry: &Entry) -> Option<String> {
873 let raw_comment = entry.comment.as_deref()?;
874 let comment = match annotation_format {
875 AnnotationFormat::Xcstrings => raw_comment.trim().to_string(),
876 AnnotationFormat::Strings => normalize_strings_comment(raw_comment),
877 AnnotationFormat::AndroidStrings => normalize_inline_comment(raw_comment),
878 };
879
880 (!comment.is_empty()).then_some(comment)
881}
882
883fn normalize_strings_comment(raw_comment: &str) -> String {
884 let stripped = if raw_comment.starts_with("/*") && raw_comment.ends_with("*/") {
885 raw_comment[2..raw_comment.len() - 2].trim()
886 } else if let Some(comment) = raw_comment.strip_prefix("//") {
887 comment.trim()
888 } else {
889 raw_comment.trim()
890 };
891
892 extract_generated_comment_body(stripped)
893 .unwrap_or(stripped)
894 .trim()
895 .to_string()
896}
897
898fn normalize_inline_comment(raw_comment: &str) -> String {
899 let trimmed = raw_comment.trim();
900 extract_generated_comment_body(trimmed)
901 .unwrap_or(trimmed)
902 .trim()
903 .to_string()
904}
905
906fn extract_generated_comment_body(comment: &str) -> Option<&str> {
907 let trimmed = comment.trim();
908 if trimmed == GENERATED_COMMENT_MARKER {
909 return Some("");
910 }
911
912 trimmed
913 .strip_prefix(GENERATED_COMMENT_MARKER)
914 .map(str::trim_start)
915}
916
917fn is_generated_inline_comment(annotation_format: AnnotationFormat, raw_comment: &str) -> bool {
918 match annotation_format {
919 AnnotationFormat::Xcstrings => false,
920 AnnotationFormat::Strings => {
921 extract_generated_comment_body(&normalize_strings_comment_storage(raw_comment))
922 .is_some()
923 }
924 AnnotationFormat::AndroidStrings => extract_generated_comment_body(raw_comment).is_some(),
925 }
926}
927
928fn normalize_strings_comment_storage(raw_comment: &str) -> String {
929 if raw_comment.starts_with("/*") && raw_comment.ends_with("*/") {
930 raw_comment[2..raw_comment.len() - 2].trim().to_string()
931 } else if let Some(comment) = raw_comment.strip_prefix("//") {
932 comment.trim().to_string()
933 } else {
934 raw_comment.trim().to_string()
935 }
936}
937
938fn generated_comment_storage(annotation_format: AnnotationFormat, comment: &str) -> String {
939 match annotation_format {
940 AnnotationFormat::Xcstrings => comment.to_string(),
941 AnnotationFormat::Strings => {
942 let body = comment.replace("*/", "* /").trim().to_string();
943 format!("/* {}\n{} */", GENERATED_COMMENT_MARKER, body)
944 }
945 AnnotationFormat::AndroidStrings => {
946 format!("{}\n{}", GENERATED_COMMENT_MARKER, comment.trim())
947 }
948 }
949}
950
951fn apply_annotation(
952 codec: &mut Codec,
953 annotation_format: AnnotationFormat,
954 key: &str,
955 comment: &str,
956) -> Result<bool, String> {
957 let stored_comment = generated_comment_storage(annotation_format, comment);
958 let mut changed = false;
959 let mut matched = false;
960
961 for resource in &mut codec.resources {
962 for entry in &mut resource.entries {
963 if entry.id != key {
964 continue;
965 }
966
967 matched = true;
968 match annotation_format {
969 AnnotationFormat::Xcstrings => {
970 let already_generated = entry
971 .custom
972 .get("is_comment_auto_generated")
973 .and_then(|value| value.parse::<bool>().ok())
974 .unwrap_or(false);
975 if entry.comment.as_deref() != Some(comment) || !already_generated {
976 changed = true;
977 }
978 entry.comment = Some(comment.to_string());
979 entry
980 .custom
981 .insert("is_comment_auto_generated".to_string(), "true".to_string());
982 }
983 AnnotationFormat::Strings | AnnotationFormat::AndroidStrings => {
984 if entry.comment.as_deref() != Some(stored_comment.as_str()) {
985 changed = true;
986 }
987 entry.comment = Some(stored_comment.clone());
988 }
989 }
990 }
991 }
992
993 if !matched {
994 return Err(format!(
995 "Annotation target '{}' was not found in loaded resources",
996 key
997 ));
998 }
999
1000 Ok(changed)
1001}
1002
1003fn write_annotated_codec(
1004 codec: &Codec,
1005 annotation_format: AnnotationFormat,
1006 output: &str,
1007) -> Result<(), String> {
1008 match annotation_format {
1009 AnnotationFormat::Xcstrings => XcstringsFormat::try_from(codec.resources.clone())
1010 .map_err(|e| format!("Failed to build xcstrings output: {}", e))?
1011 .write_to(output)
1012 .map_err(|e| e.to_string()),
1013 AnnotationFormat::Strings => {
1014 let resource = single_resource_for_annotation(codec, output)?;
1015 StringsFormat::try_from(resource.clone())
1016 .map_err(|e| format!("Failed to build .strings output: {}", e))?
1017 .write_to(output)
1018 .map_err(|e| e.to_string())
1019 }
1020 AnnotationFormat::AndroidStrings => {
1021 let resource = single_resource_for_annotation(codec, output)?;
1022 AndroidStringsFormat::from(resource.clone())
1023 .write_to(output)
1024 .map_err(|e| e.to_string())
1025 }
1026 }
1027}
1028
1029fn single_resource_for_annotation<'a>(
1030 codec: &'a Codec,
1031 output: &str,
1032) -> Result<&'a Resource, String> {
1033 if codec.resources.len() != 1 {
1034 return Err(format!(
1035 "Expected exactly one resource when writing '{}', found {}",
1036 output,
1037 codec.resources.len()
1038 ));
1039 }
1040
1041 Ok(&codec.resources[0])
1042}
1043
1044fn create_annotate_reporter(
1045 opts: &ResolvedAnnotateOptions,
1046 source_lang: &str,
1047 requests: &[AnnotationRequest],
1048) -> Result<Box<dyn RunReporter>, String> {
1049 let init = DashboardInit {
1050 kind: DashboardKind::Annotate,
1051 title: Path::new(&opts.input)
1052 .file_name()
1053 .and_then(|name| name.to_str())
1054 .unwrap_or(opts.input.as_str())
1055 .to_string(),
1056 metadata: annotate_metadata_rows(opts, source_lang),
1057 summary_rows: annotate_summary_rows(requests.len(), 0, 0),
1058 items: requests.iter().map(annotate_dashboard_item).collect(),
1059 };
1060 match opts.ui_mode {
1061 ResolvedUiMode::Plain => Ok(Box::new(PlainReporter::new(init))),
1062 ResolvedUiMode::Tui => Ok(Box::new(TuiReporter::new(init)?)),
1063 }
1064}
1065
1066fn annotate_metadata_rows(opts: &ResolvedAnnotateOptions, source_lang: &str) -> Vec<SummaryRow> {
1067 let mut rows = vec![
1068 SummaryRow::new(
1069 "Provider",
1070 format!("{}:{}", opts.provider.display_name(), opts.model),
1071 ),
1072 SummaryRow::new("Input", opts.input.clone()),
1073 SummaryRow::new("Output", opts.output.clone()),
1074 SummaryRow::new("Source language", source_lang.to_string()),
1075 SummaryRow::new("Concurrency", opts.concurrency.to_string()),
1076 ];
1077 if opts.dry_run {
1078 rows.push(SummaryRow::new("Mode", "dry-run"));
1079 }
1080 if opts.check {
1081 rows.push(SummaryRow::new("Check", "enabled"));
1082 }
1083 rows
1084}
1085
1086fn annotate_summary_rows(total: usize, generated: usize, unmatched: usize) -> Vec<SummaryRow> {
1087 vec![
1088 SummaryRow::new("Total", total.to_string()),
1089 SummaryRow::new("Generated", generated.to_string()),
1090 SummaryRow::new("Skipped", unmatched.to_string()),
1091 ]
1092}
1093
1094fn annotate_dashboard_item(request: &AnnotationRequest) -> DashboardItem {
1095 let mut item = DashboardItem::new(
1096 request.key.clone(),
1097 request.key.clone(),
1098 request.source_lang.clone(),
1099 DashboardItemStatus::Queued,
1100 );
1101 item.source_text = Some(request.source_value.clone());
1102 item.note_text = request.existing_comment.clone();
1103 item
1104}
1105
1106fn annotate_worker_started_message(
1107 worker_id: usize,
1108 key: &str,
1109 candidate_count: usize,
1110 top_candidate: Option<&str>,
1111) -> String {
1112 let mut message = format!(
1113 "Worker {} started key={} shortlist={}",
1114 worker_id, key, candidate_count
1115 );
1116 if let Some(path) = top_candidate {
1117 message.push_str(" top=");
1118 message.push_str(path);
1119 }
1120 message
1121}
1122
1123fn annotate_worker_finished_message(
1124 worker_id: usize,
1125 key: &str,
1126 result: &Option<AnnotationResponse>,
1127) -> String {
1128 let status = if result.is_some() {
1129 "generated"
1130 } else {
1131 "skipped"
1132 };
1133 format!(
1134 "Worker {} finished key={} result={}",
1135 worker_id, key, status
1136 )
1137}
1138
1139fn source_value_map(resources: &[Resource], source_lang: &str) -> HashMap<String, String> {
1140 resources
1141 .iter()
1142 .find(|resource| lang_matches(&resource.metadata.language, source_lang))
1143 .map(|resource| {
1144 resource
1145 .entries
1146 .iter()
1147 .map(|entry| {
1148 (
1149 entry.id.clone(),
1150 translation_to_text(&entry.value, &entry.id),
1151 )
1152 })
1153 .collect()
1154 })
1155 .unwrap_or_default()
1156}
1157
1158fn translation_to_text(value: &Translation, fallback_key: &str) -> String {
1159 match value {
1160 Translation::Empty => fallback_key.to_string(),
1161 Translation::Singular(text) => text.clone(),
1162 Translation::Plural(plural) => plural
1163 .forms
1164 .values()
1165 .next()
1166 .cloned()
1167 .unwrap_or_else(|| fallback_key.to_string()),
1168 }
1169}
1170
1171fn build_agent_config(workspace_root: &Path) -> AgentConfig {
1172 AgentConfig {
1173 system: Some(ANNOTATION_SYSTEM_PROMPT.to_string()),
1174 temperature: Some(0.2),
1175 max_output_tokens: Some(512),
1176 tool_profile: ToolProfile::only(["files", "shell"]),
1177 provider_request_options: ProviderRequestOptions {
1178 responses: ResponsesRequestOptions {
1179 parallel_tool_calls: Some(false),
1180 ..ResponsesRequestOptions::default()
1181 },
1182 ..ProviderRequestOptions::default()
1183 },
1184 workspace: WorkspaceConfig {
1185 base_dir: workspace_root.to_path_buf(),
1186 auto_route_shell: false,
1187 },
1188 ..AgentConfig::default()
1189 }
1190}
1191
1192fn build_annotation_prompt(request: &AnnotationRequest) -> String {
1193 let mut prompt = format!(
1194 "Write one translator-facing comment for this localization entry.\n\nKey: {}\nSource language: {}\nSource value: {}\n",
1195 request.key, request.source_lang, request.source_value
1196 );
1197
1198 if let Some(existing_comment) = &request.existing_comment {
1199 prompt.push_str("\nExisting auto-generated comment:\n");
1200 prompt.push_str(existing_comment);
1201 prompt.push('\n');
1202 }
1203
1204 prompt.push_str("\nSource roots you may inspect with the files tool:\n");
1205 for root in &request.source_roots {
1206 prompt.push_str("- ");
1207 prompt.push_str(root);
1208 prompt.push('\n');
1209 }
1210
1211 prompt.push_str(
1212 "\nUse the shell tool for fast code search, preferably with rg, within these roots before drafting when the usage is not already obvious. Then use files reads for only the most relevant hits. Avoid broad repeated searches or directory listings.\n",
1213 );
1214
1215 prompt.push_str(
1216 "\nRequirements:\n- Keep the comment concise and useful for translators.\n- Prefer describing UI role or user-facing context.\n- If confidence is low, mention the concrete code usage you found instead of guessing product meaning.\n- Use as few tool calls as practical; usually one rg search plus a small number of targeted file reads is enough.\n- Do not mention internal file paths unless they clarify usage.\n- Return JSON only: {\"comment\":\"...\",\"confidence\":\"high|medium|low\"}.\n",
1217 );
1218 prompt
1219}
1220
1221fn spawn_tool_call_logger(
1222 mut events: broadcast::Receiver<AgentEvent>,
1223 key: String,
1224 event_tx: Option<mpsc::UnboundedSender<WorkerUpdate>>,
1225) -> tokio::task::JoinHandle<()> {
1226 tokio::spawn(async move {
1227 loop {
1228 match events.recv().await {
1229 Ok(AgentEvent::ToolExecutionStarted { call }) => {
1230 if let Some(tx) = &event_tx {
1231 let _ = tx.send(WorkerUpdate::ToolCall {
1232 tone: DashboardLogTone::Info,
1233 message: format!(
1234 "Tool call key={} tool={} input={}",
1235 key,
1236 call.name,
1237 compact_tool_input(&call.input)
1238 ),
1239 });
1240 }
1241 }
1242 Ok(AgentEvent::ToolExecutionFinished { result }) => {
1243 let status = match result {
1244 ContentBlock::ToolResult { is_error, .. } if is_error => "error",
1245 ContentBlock::ToolResult { .. } => "ok",
1246 _ => "unknown",
1247 };
1248 if let Some(tx) = &event_tx {
1249 let tone = if status == "error" {
1250 DashboardLogTone::Error
1251 } else {
1252 DashboardLogTone::Success
1253 };
1254 let _ = tx.send(WorkerUpdate::ToolCall {
1255 tone,
1256 message: format!("Tool result key={} status={}", key, status),
1257 });
1258 }
1259 }
1260 Ok(_) => {}
1261 Err(broadcast::error::RecvError::Closed) => break,
1262 Err(broadcast::error::RecvError::Lagged(_)) => continue,
1263 }
1264 }
1265 })
1266}
1267
1268fn compact_tool_input(input: &Value) -> String {
1269 const MAX_TOOL_INPUT_CHARS: usize = 180;
1270
1271 let rendered = serde_json::to_string(input).unwrap_or_else(|_| "<unserializable>".to_string());
1272 let mut preview = rendered
1273 .chars()
1274 .take(MAX_TOOL_INPUT_CHARS)
1275 .collect::<String>();
1276 if rendered.chars().count() > MAX_TOOL_INPUT_CHARS {
1277 preview.push_str("...");
1278 }
1279 preview
1280}
1281
1282fn parse_annotation_response(text: &str) -> Result<AnnotationResponse, String> {
1283 let trimmed = text.trim();
1284 if trimmed.is_empty() {
1285 return Err("Model returned an empty annotation response".to_string());
1286 }
1287
1288 if let Ok(payload) = serde_json::from_str::<AnnotationResponse>(trimmed) {
1289 return validate_annotation_response(payload);
1290 }
1291
1292 if let Some(json_body) = extract_json_body(trimmed)
1293 && let Ok(payload) = serde_json::from_str::<AnnotationResponse>(&json_body)
1294 {
1295 return validate_annotation_response(payload);
1296 }
1297
1298 Err(format!(
1299 "Model response was not valid annotation JSON: {}",
1300 trimmed
1301 ))
1302}
1303
1304fn validate_annotation_response(payload: AnnotationResponse) -> Result<AnnotationResponse, String> {
1305 if payload.comment.trim().is_empty() {
1306 return Err("Model returned an empty annotation comment".to_string());
1307 }
1308 Ok(payload)
1309}
1310
1311fn extract_json_body(text: &str) -> Option<String> {
1312 let fenced = text
1313 .strip_prefix("```json")
1314 .or_else(|| text.strip_prefix("```"))
1315 .map(str::trim_start)?;
1316 let unfenced = fenced.strip_suffix("```")?.trim();
1317 Some(unfenced.to_string())
1318}
1319
1320fn absolutize_path(path: &str, cwd: &Path) -> String {
1321 let candidate = Path::new(path);
1322 if candidate.is_absolute() {
1323 candidate.to_string_lossy().to_string()
1324 } else {
1325 cwd.join(candidate).to_string_lossy().to_string()
1326 }
1327}
1328
1329fn derive_workspace_root(input: &str, source_roots: &[String], fallback: &Path) -> PathBuf {
1330 let mut candidates = Vec::new();
1331 candidates.push(path_root_candidate(Path::new(input)));
1332 for root in source_roots {
1333 candidates.push(path_root_candidate(Path::new(root)));
1334 }
1335
1336 common_ancestor(candidates.into_iter().flatten().collect::<Vec<_>>())
1337 .unwrap_or_else(|| fallback.to_path_buf())
1338}
1339
1340fn path_root_candidate(path: &Path) -> Option<PathBuf> {
1341 let absolute = fs::canonicalize(path).ok().or_else(|| {
1342 if path.is_absolute() {
1343 Some(path.to_path_buf())
1344 } else {
1345 None
1346 }
1347 })?;
1348
1349 if absolute.is_dir() {
1350 Some(absolute)
1351 } else {
1352 absolute.parent().map(Path::to_path_buf)
1353 }
1354}
1355
1356fn common_ancestor(paths: Vec<PathBuf>) -> Option<PathBuf> {
1357 let mut iter = paths.into_iter();
1358 let first = iter.next()?;
1359 let mut current = first;
1360
1361 for path in iter {
1362 let mut next = current.clone();
1363 while !path.starts_with(&next) {
1364 if !next.pop() {
1365 return None;
1366 }
1367 }
1368 current = next;
1369 }
1370
1371 Some(current)
1372}
1373
1374fn display_path(workspace_root: &Path, path: &Path) -> String {
1375 path.strip_prefix(workspace_root)
1376 .map(|relative| relative.to_string_lossy().to_string())
1377 .unwrap_or_else(|_| path.to_string_lossy().to_string())
1378}
1379
1380fn lang_matches(left: &str, right: &str) -> bool {
1381 normalize_lang(left) == normalize_lang(right)
1382}
1383
1384fn normalize_lang(lang: &str) -> String {
1385 lang.trim().replace('_', "-").to_ascii_lowercase()
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390 use super::*;
1391 use mentra::{
1392 BuiltinProvider, ModelInfo, ProviderDescriptor,
1393 provider::{
1394 ContentBlockDelta, ContentBlockStart, Provider, ProviderEvent, ProviderEventStream,
1395 Request, Response, Role, provider_event_stream_from_response,
1396 },
1397 runtime::RunOptions,
1398 };
1399 use std::sync::{Arc, Mutex};
1400 use tempfile::TempDir;
1401
1402 struct FakeBackend {
1403 responses: HashMap<String, Option<AnnotationResponse>>,
1404 }
1405
1406 #[async_trait]
1407 impl AnnotationBackend for FakeBackend {
1408 async fn annotate(
1409 &self,
1410 request: AnnotationRequest,
1411 _event_tx: Option<mpsc::UnboundedSender<WorkerUpdate>>,
1412 ) -> Result<Option<AnnotationResponse>, String> {
1413 Ok(self.responses.get(&request.key).cloned().flatten())
1414 }
1415 }
1416
1417 struct RuntimeHoldingBackend {
1418 _runtime: Arc<tokio::runtime::Runtime>,
1419 }
1420
1421 #[async_trait]
1422 impl AnnotationBackend for RuntimeHoldingBackend {
1423 async fn annotate(
1424 &self,
1425 _request: AnnotationRequest,
1426 _event_tx: Option<mpsc::UnboundedSender<WorkerUpdate>>,
1427 ) -> Result<Option<AnnotationResponse>, String> {
1428 Ok(Some(AnnotationResponse {
1429 comment: "Generated comment".to_string(),
1430 confidence: "high".to_string(),
1431 }))
1432 }
1433 }
1434
1435 struct RecordingProvider {
1436 requests: Arc<Mutex<Vec<Request<'static>>>>,
1437 }
1438
1439 struct ScriptedStreamingProvider {
1440 requests: Arc<Mutex<Vec<Request<'static>>>>,
1441 scripts: Arc<Mutex<VecDeque<Vec<ProviderEvent>>>>,
1442 }
1443
1444 #[async_trait]
1445 impl Provider for RecordingProvider {
1446 fn descriptor(&self) -> ProviderDescriptor {
1447 ProviderDescriptor::new(BuiltinProvider::OpenAI)
1448 }
1449
1450 async fn list_models(&self) -> Result<Vec<ModelInfo>, mentra::provider::ProviderError> {
1451 Ok(vec![ModelInfo::new("test-model", BuiltinProvider::OpenAI)])
1452 }
1453
1454 async fn stream(
1455 &self,
1456 request: Request<'_>,
1457 ) -> Result<ProviderEventStream, mentra::provider::ProviderError> {
1458 self.requests
1459 .lock()
1460 .expect("requests lock")
1461 .push(request.clone().into_owned());
1462 Ok(provider_event_stream_from_response(Response {
1463 id: "resp-1".to_string(),
1464 model: request.model.to_string(),
1465 role: Role::Assistant,
1466 content: vec![ContentBlock::text(
1467 r#"{"comment":"A button label that starts the game.","confidence":"high"}"#,
1468 )],
1469 stop_reason: Some("end_turn".to_string()),
1470 usage: None,
1471 }))
1472 }
1473 }
1474
1475 #[async_trait]
1476 impl Provider for ScriptedStreamingProvider {
1477 fn descriptor(&self) -> ProviderDescriptor {
1478 ProviderDescriptor::new(BuiltinProvider::OpenAI)
1479 }
1480
1481 async fn list_models(&self) -> Result<Vec<ModelInfo>, mentra::provider::ProviderError> {
1482 Ok(vec![ModelInfo::new("test-model", BuiltinProvider::OpenAI)])
1483 }
1484
1485 async fn stream(
1486 &self,
1487 request: Request<'_>,
1488 ) -> Result<ProviderEventStream, mentra::provider::ProviderError> {
1489 self.requests
1490 .lock()
1491 .expect("requests lock")
1492 .push(request.clone().into_owned());
1493 let script = self
1494 .scripts
1495 .lock()
1496 .expect("scripts lock")
1497 .pop_front()
1498 .expect("missing scripted response");
1499
1500 let (tx, rx) = mpsc::unbounded_channel();
1501 for event in script {
1502 tx.send(Ok(event)).expect("send provider event");
1503 }
1504 Ok(rx)
1505 }
1506 }
1507
1508 #[test]
1509 fn build_agent_config_limits_tools_to_files() {
1510 let config = build_agent_config(Path::new("/tmp/project"));
1511 assert!(config.tool_profile.allows("files"));
1512 assert!(config.tool_profile.allows("shell"));
1513 assert!(!config.tool_profile.allows("task"));
1514 }
1515
1516 #[test]
1517 fn parse_annotation_response_accepts_fenced_json() {
1518 let parsed = parse_annotation_response(
1519 "```json\n{\"comment\":\"Dialog title for room exit confirmation.\",\"confidence\":\"medium\"}\n```",
1520 )
1521 .expect("parse response");
1522 assert_eq!(
1523 parsed,
1524 AnnotationResponse {
1525 comment: "Dialog title for room exit confirmation.".to_string(),
1526 confidence: "medium".to_string(),
1527 }
1528 );
1529 }
1530
1531 #[test]
1532 fn run_annotate_updates_missing_and_auto_generated_comments_only() {
1533 let temp_dir = TempDir::new().expect("temp dir");
1534 let input = temp_dir.path().join("Localizable.xcstrings");
1535 let source_root = temp_dir.path().join("Sources");
1536 fs::create_dir_all(&source_root).expect("create root");
1537 fs::write(
1538 source_root.join("GameView.swift"),
1539 r#"Text("Start", bundle: .module)"#,
1540 )
1541 .expect("write swift");
1542 fs::write(
1543 &input,
1544 r#"{
1545 "sourceLanguage": "en",
1546 "version": "1.0",
1547 "strings": {
1548 "start": {
1549 "localizations": {
1550 "en": { "stringUnit": { "state": "translated", "value": "Start" } }
1551 }
1552 },
1553 "cancel": {
1554 "comment": "Written by a human.",
1555 "localizations": {
1556 "en": { "stringUnit": { "state": "translated", "value": "Cancel" } }
1557 }
1558 },
1559 "retry": {
1560 "comment": "Old auto comment",
1561 "isCommentAutoGenerated": true,
1562 "localizations": {
1563 "en": { "stringUnit": { "state": "translated", "value": "Retry" } }
1564 }
1565 }
1566 }
1567}"#,
1568 )
1569 .expect("write xcstrings");
1570
1571 let mut responses = HashMap::new();
1572 responses.insert(
1573 "start".to_string(),
1574 Some(AnnotationResponse {
1575 comment: "A button label that starts the game.".to_string(),
1576 confidence: "high".to_string(),
1577 }),
1578 );
1579 responses.insert(
1580 "retry".to_string(),
1581 Some(AnnotationResponse {
1582 comment: "A button label shown when the user can try the action again.".to_string(),
1583 confidence: "high".to_string(),
1584 }),
1585 );
1586
1587 let opts = ResolvedAnnotateOptions {
1588 input: input.to_string_lossy().to_string(),
1589 output: input.to_string_lossy().to_string(),
1590 source_roots: vec![source_root.to_string_lossy().to_string()],
1591 source_lang: Some("en".to_string()),
1592 provider: ProviderKind::OpenAI,
1593 model: "test-model".to_string(),
1594 concurrency: 1,
1595 dry_run: false,
1596 check: false,
1597 workspace_root: temp_dir.path().to_path_buf(),
1598 ui_mode: ResolvedUiMode::Plain,
1599 };
1600
1601 run_annotate_with_backend(opts, Arc::new(FakeBackend { responses }))
1602 .expect("annotate command");
1603
1604 let payload = serde_json::from_str::<serde_json::Value>(
1605 &fs::read_to_string(&input).expect("read output"),
1606 )
1607 .expect("parse output");
1608
1609 assert_eq!(
1610 payload["strings"]["start"]["comment"],
1611 serde_json::Value::String("A button label that starts the game.".to_string())
1612 );
1613 assert_eq!(
1614 payload["strings"]["start"]["isCommentAutoGenerated"],
1615 serde_json::Value::Bool(true)
1616 );
1617 assert_eq!(
1618 payload["strings"]["retry"]["comment"],
1619 serde_json::Value::String(
1620 "A button label shown when the user can try the action again.".to_string()
1621 )
1622 );
1623 assert_eq!(
1624 payload["strings"]["cancel"]["comment"],
1625 serde_json::Value::String("Written by a human.".to_string())
1626 );
1627 }
1628
1629 #[test]
1630 fn run_annotate_supports_apple_strings_files() {
1631 let temp_dir = TempDir::new().expect("temp dir");
1632 let input_dir = temp_dir.path().join("en.lproj");
1633 let input = input_dir.join("Localizable.strings");
1634 let source_root = temp_dir.path().join("Sources");
1635 fs::create_dir_all(&input_dir).expect("create input dir");
1636 fs::create_dir_all(&source_root).expect("create root");
1637 fs::write(
1638 &input,
1639 r#"/* Written by a human. */
1640"cancel" = "Cancel";
1641"start" = "Start";
1642/* langcodec:auto-generated
1643Old auto comment */
1644"retry" = "Retry";
1645"#,
1646 )
1647 .expect("write strings");
1648
1649 let mut responses = HashMap::new();
1650 responses.insert(
1651 "start".to_string(),
1652 Some(AnnotationResponse {
1653 comment: "A button label that starts the game.".to_string(),
1654 confidence: "high".to_string(),
1655 }),
1656 );
1657 responses.insert(
1658 "retry".to_string(),
1659 Some(AnnotationResponse {
1660 comment: "A button label shown when the user can try the action again.".to_string(),
1661 confidence: "high".to_string(),
1662 }),
1663 );
1664
1665 let opts = ResolvedAnnotateOptions {
1666 input: input.to_string_lossy().to_string(),
1667 output: input.to_string_lossy().to_string(),
1668 source_roots: vec![source_root.to_string_lossy().to_string()],
1669 source_lang: Some("en".to_string()),
1670 provider: ProviderKind::OpenAI,
1671 model: "test-model".to_string(),
1672 concurrency: 1,
1673 dry_run: false,
1674 check: false,
1675 workspace_root: temp_dir.path().to_path_buf(),
1676 ui_mode: ResolvedUiMode::Plain,
1677 };
1678
1679 run_annotate_with_backend(opts, Arc::new(FakeBackend { responses }))
1680 .expect("annotate strings");
1681
1682 let format = StringsFormat::read_from(&input).expect("read strings output");
1683 let mut comments = HashMap::new();
1684 for pair in format.pairs {
1685 let key = pair.key.clone();
1686 comments.insert(
1687 key,
1688 pair.comment
1689 .as_deref()
1690 .map(normalize_strings_comment)
1691 .unwrap_or_default(),
1692 );
1693 }
1694
1695 assert_eq!(
1696 comments.get("start").map(String::as_str),
1697 Some("A button label that starts the game.")
1698 );
1699 assert_eq!(
1700 comments.get("retry").map(String::as_str),
1701 Some("A button label shown when the user can try the action again.")
1702 );
1703 assert_eq!(
1704 comments.get("cancel").map(String::as_str),
1705 Some("Written by a human.")
1706 );
1707
1708 let written = fs::read_to_string(&input).expect("read written strings");
1709 assert!(written.contains("langcodec:auto-generated"));
1710 }
1711
1712 #[test]
1713 fn run_annotate_supports_android_strings_files() {
1714 let temp_dir = TempDir::new().expect("temp dir");
1715 let values_dir = temp_dir.path().join("values");
1716 let input = values_dir.join("strings.xml");
1717 let source_root = temp_dir.path().join("Sources");
1718 fs::create_dir_all(&values_dir).expect("create values dir");
1719 fs::create_dir_all(&source_root).expect("create root");
1720 fs::write(
1721 &input,
1722 r#"<resources>
1723<!-- Written by a human. -->
1724<string name="cancel">Cancel</string>
1725<string name="start">Start</string>
1726<!-- langcodec:auto-generated
1727Old auto comment -->
1728<string name="retry">Retry</string>
1729<plurals name="apples">
1730<item quantity="one">One apple</item>
1731<item quantity="other">%d apples</item>
1732</plurals>
1733</resources>
1734"#,
1735 )
1736 .expect("write xml");
1737
1738 let mut responses = HashMap::new();
1739 responses.insert(
1740 "start".to_string(),
1741 Some(AnnotationResponse {
1742 comment: "A button label that starts the game.".to_string(),
1743 confidence: "high".to_string(),
1744 }),
1745 );
1746 responses.insert(
1747 "retry".to_string(),
1748 Some(AnnotationResponse {
1749 comment: "A button label shown when the user can try the action again.".to_string(),
1750 confidence: "high".to_string(),
1751 }),
1752 );
1753 responses.insert(
1754 "apples".to_string(),
1755 Some(AnnotationResponse {
1756 comment: "Pluralized inventory count for apples.".to_string(),
1757 confidence: "high".to_string(),
1758 }),
1759 );
1760
1761 let opts = ResolvedAnnotateOptions {
1762 input: input.to_string_lossy().to_string(),
1763 output: input.to_string_lossy().to_string(),
1764 source_roots: vec![source_root.to_string_lossy().to_string()],
1765 source_lang: Some("en".to_string()),
1766 provider: ProviderKind::OpenAI,
1767 model: "test-model".to_string(),
1768 concurrency: 1,
1769 dry_run: false,
1770 check: false,
1771 workspace_root: temp_dir.path().to_path_buf(),
1772 ui_mode: ResolvedUiMode::Plain,
1773 };
1774
1775 run_annotate_with_backend(opts, Arc::new(FakeBackend { responses }))
1776 .expect("annotate android");
1777
1778 let format = AndroidStringsFormat::read_from(&input).expect("read android output");
1779 let mut string_comments = HashMap::new();
1780 for item in format.strings {
1781 string_comments.insert(item.name, item.comment.unwrap_or_default());
1782 }
1783 let mut plural_comments = HashMap::new();
1784 for item in format.plurals {
1785 plural_comments.insert(item.name, item.comment.unwrap_or_default());
1786 }
1787
1788 assert_eq!(
1789 normalize_inline_comment(string_comments["start"].as_str()),
1790 "A button label that starts the game."
1791 );
1792 assert_eq!(
1793 normalize_inline_comment(string_comments["retry"].as_str()),
1794 "A button label shown when the user can try the action again."
1795 );
1796 assert_eq!(
1797 normalize_inline_comment(string_comments["cancel"].as_str()),
1798 "Written by a human."
1799 );
1800 assert_eq!(
1801 normalize_inline_comment(plural_comments["apples"].as_str()),
1802 "Pluralized inventory count for apples."
1803 );
1804
1805 let written = fs::read_to_string(&input).expect("read written xml");
1806 assert!(written.contains("langcodec:auto-generated"));
1807 }
1808
1809 #[test]
1810 fn run_annotate_dry_run_does_not_write_changes() {
1811 let temp_dir = TempDir::new().expect("temp dir");
1812 let input = temp_dir.path().join("Localizable.xcstrings");
1813 let source_root = temp_dir.path().join("Sources");
1814 fs::create_dir_all(&source_root).expect("create root");
1815 fs::write(
1816 &input,
1817 r#"{
1818 "sourceLanguage": "en",
1819 "version": "1.0",
1820 "strings": {
1821 "start": {
1822 "localizations": {
1823 "en": { "stringUnit": { "state": "translated", "value": "Start" } }
1824 }
1825 }
1826 }
1827}"#,
1828 )
1829 .expect("write xcstrings");
1830
1831 let original = fs::read_to_string(&input).expect("read original");
1832 let mut responses = HashMap::new();
1833 responses.insert(
1834 "start".to_string(),
1835 Some(AnnotationResponse {
1836 comment: "A button label that starts the game.".to_string(),
1837 confidence: "high".to_string(),
1838 }),
1839 );
1840
1841 let opts = ResolvedAnnotateOptions {
1842 input: input.to_string_lossy().to_string(),
1843 output: input.to_string_lossy().to_string(),
1844 source_roots: vec![source_root.to_string_lossy().to_string()],
1845 source_lang: Some("en".to_string()),
1846 provider: ProviderKind::OpenAI,
1847 model: "test-model".to_string(),
1848 concurrency: 1,
1849 dry_run: true,
1850 check: false,
1851 workspace_root: temp_dir.path().to_path_buf(),
1852 ui_mode: ResolvedUiMode::Plain,
1853 };
1854
1855 run_annotate_with_backend(opts, Arc::new(FakeBackend { responses }))
1856 .expect("annotate command");
1857
1858 assert_eq!(fs::read_to_string(&input).expect("read output"), original);
1859 }
1860
1861 #[test]
1862 fn run_annotate_check_fails_when_changes_would_be_written() {
1863 let temp_dir = TempDir::new().expect("temp dir");
1864 let input = temp_dir.path().join("Localizable.xcstrings");
1865 let source_root = temp_dir.path().join("Sources");
1866 fs::create_dir_all(&source_root).expect("create root");
1867 fs::write(
1868 &input,
1869 r#"{
1870 "sourceLanguage": "en",
1871 "version": "1.0",
1872 "strings": {
1873 "start": {
1874 "localizations": {
1875 "en": { "stringUnit": { "state": "translated", "value": "Start" } }
1876 }
1877 }
1878 }
1879}"#,
1880 )
1881 .expect("write xcstrings");
1882
1883 let mut responses = HashMap::new();
1884 responses.insert(
1885 "start".to_string(),
1886 Some(AnnotationResponse {
1887 comment: "A button label that starts the game.".to_string(),
1888 confidence: "high".to_string(),
1889 }),
1890 );
1891
1892 let opts = ResolvedAnnotateOptions {
1893 input: input.to_string_lossy().to_string(),
1894 output: input.to_string_lossy().to_string(),
1895 source_roots: vec![source_root.to_string_lossy().to_string()],
1896 source_lang: Some("en".to_string()),
1897 provider: ProviderKind::OpenAI,
1898 model: "test-model".to_string(),
1899 concurrency: 1,
1900 dry_run: false,
1901 check: true,
1902 workspace_root: temp_dir.path().to_path_buf(),
1903 ui_mode: ResolvedUiMode::Plain,
1904 };
1905
1906 let error = run_annotate_with_backend(opts, Arc::new(FakeBackend { responses }))
1907 .expect_err("check mode should fail");
1908 assert!(error.contains("would change"));
1909 }
1910
1911 #[test]
1912 fn annotate_requests_does_not_drop_backend_runtime_inside_async_context() {
1913 let requests = vec![AnnotationRequest {
1914 key: "start".to_string(),
1915 source_lang: "en".to_string(),
1916 source_value: "Start".to_string(),
1917 existing_comment: None,
1918 source_roots: vec!["Sources".to_string()],
1919 }];
1920 let backend: Arc<dyn AnnotationBackend> = Arc::new(RuntimeHoldingBackend {
1921 _runtime: Arc::new(
1922 tokio::runtime::Builder::new_current_thread()
1923 .enable_all()
1924 .build()
1925 .expect("build nested runtime"),
1926 ),
1927 });
1928 let init = DashboardInit {
1929 kind: DashboardKind::Annotate,
1930 title: "test".to_string(),
1931 metadata: Vec::new(),
1932 summary_rows: annotate_summary_rows(1, 0, 0),
1933 items: requests.iter().map(annotate_dashboard_item).collect(),
1934 };
1935 let mut reporter = PlainReporter::new(init);
1936
1937 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1938 annotate_requests(requests, Arc::clone(&backend), 1, &mut reporter)
1939 }));
1940
1941 assert!(result.is_ok(), "annotate_requests should not panic");
1942 let annotations = result.expect("no panic").expect("annotation results");
1943 assert_eq!(annotations.len(), 1);
1944 assert!(annotations["start"].is_some());
1945 }
1946
1947 #[test]
1948 fn resolve_annotate_options_uses_provider_section_defaults() {
1949 let temp_dir = TempDir::new().expect("temp dir");
1950 let project_dir = temp_dir.path().join("project");
1951 let sources_dir = project_dir.join("Sources");
1952 let modules_dir = project_dir.join("Modules");
1953 fs::create_dir_all(&sources_dir).expect("create Sources");
1954 fs::create_dir_all(&modules_dir).expect("create Modules");
1955 let input = project_dir.join("Localizable.xcstrings");
1956 fs::write(
1957 &input,
1958 r#"{
1959 "sourceLanguage": "en",
1960 "version": "1.0",
1961 "strings": {}
1962}"#,
1963 )
1964 .expect("write xcstrings");
1965
1966 let config_path = project_dir.join("langcodec.toml");
1967 fs::write(
1968 &config_path,
1969 r#"[openai]
1970model = "gpt-5.4"
1971
1972[annotate]
1973input = "Localizable.xcstrings"
1974source_roots = ["Sources", "Modules"]
1975output = "Annotated.xcstrings"
1976source_lang = "en"
1977concurrency = 2
1978"#,
1979 )
1980 .expect("write config");
1981
1982 let loaded = load_config(Some(config_path.to_str().expect("config path")))
1983 .expect("load config")
1984 .expect("config present");
1985
1986 let resolved = resolve_annotate_options(
1987 &AnnotateOptions {
1988 input: None,
1989 source_roots: Vec::new(),
1990 output: None,
1991 source_lang: None,
1992 provider: None,
1993 model: None,
1994 concurrency: None,
1995 config: Some(config_path.to_string_lossy().to_string()),
1996 dry_run: false,
1997 check: false,
1998 ui_mode: UiMode::Plain,
1999 },
2000 Some(&loaded),
2001 )
2002 .expect("resolve annotate options");
2003
2004 assert_eq!(resolved.input, input.to_string_lossy().to_string());
2005 assert_eq!(
2006 resolved.output,
2007 project_dir
2008 .join("Annotated.xcstrings")
2009 .to_string_lossy()
2010 .to_string()
2011 );
2012 assert_eq!(
2013 resolved.source_roots,
2014 vec![
2015 sources_dir.to_string_lossy().to_string(),
2016 modules_dir.to_string_lossy().to_string()
2017 ]
2018 );
2019 assert_eq!(resolved.source_lang.as_deref(), Some("en"));
2020 assert_eq!(resolved.provider, ProviderKind::OpenAI);
2021 assert_eq!(resolved.model, "gpt-5.4");
2022 assert_eq!(resolved.concurrency, 2);
2023 }
2024
2025 #[test]
2026 fn resolve_annotate_options_prefers_cli_over_config() {
2027 let temp_dir = TempDir::new().expect("temp dir");
2028 let project_dir = temp_dir.path().join("project");
2029 let config_sources_dir = project_dir.join("Sources");
2030 let cli_sources_dir = project_dir.join("AppSources");
2031 fs::create_dir_all(&config_sources_dir).expect("create config Sources");
2032 fs::create_dir_all(&cli_sources_dir).expect("create cli Sources");
2033 let config_input = project_dir.join("Localizable.xcstrings");
2034 let cli_input = project_dir.join("Runtime.xcstrings");
2035 fs::write(
2036 &config_input,
2037 r#"{
2038 "sourceLanguage": "en",
2039 "version": "1.0",
2040 "strings": {}
2041}"#,
2042 )
2043 .expect("write config xcstrings");
2044 fs::write(
2045 &cli_input,
2046 r#"{
2047 "sourceLanguage": "en",
2048 "version": "1.0",
2049 "strings": {}
2050}"#,
2051 )
2052 .expect("write cli xcstrings");
2053
2054 let config_path = project_dir.join("langcodec.toml");
2055 fs::write(
2056 &config_path,
2057 r#"[openai]
2058model = "gpt-5.4"
2059
2060[annotate]
2061input = "Localizable.xcstrings"
2062source_roots = ["Sources"]
2063source_lang = "en"
2064concurrency = 2
2065"#,
2066 )
2067 .expect("write config");
2068
2069 let loaded = load_config(Some(config_path.to_str().expect("config path")))
2070 .expect("load config")
2071 .expect("config present");
2072
2073 let resolved = resolve_annotate_options(
2074 &AnnotateOptions {
2075 input: Some(cli_input.to_string_lossy().to_string()),
2076 source_roots: vec![cli_sources_dir.to_string_lossy().to_string()],
2077 output: Some(
2078 project_dir
2079 .join("Output.xcstrings")
2080 .to_string_lossy()
2081 .to_string(),
2082 ),
2083 source_lang: Some("fr".to_string()),
2084 provider: Some("anthropic".to_string()),
2085 model: Some("claude-sonnet".to_string()),
2086 concurrency: Some(6),
2087 config: Some(config_path.to_string_lossy().to_string()),
2088 dry_run: true,
2089 check: true,
2090 ui_mode: UiMode::Plain,
2091 },
2092 Some(&loaded),
2093 )
2094 .expect("resolve annotate options");
2095
2096 assert_eq!(resolved.input, cli_input.to_string_lossy().to_string());
2097 assert_eq!(
2098 resolved.source_roots,
2099 vec![cli_sources_dir.to_string_lossy().to_string()]
2100 );
2101 assert_eq!(resolved.source_lang.as_deref(), Some("fr"));
2102 assert_eq!(resolved.provider, ProviderKind::Anthropic);
2103 assert_eq!(resolved.model, "claude-sonnet");
2104 assert_eq!(resolved.concurrency, 6);
2105 assert!(resolved.dry_run);
2106 assert!(resolved.check);
2107 }
2108
2109 #[test]
2110 fn expand_annotate_invocations_supports_multiple_config_inputs() {
2111 let temp_dir = TempDir::new().expect("temp dir");
2112 let project_dir = temp_dir.path().join("project");
2113 let sources_dir = project_dir.join("Sources");
2114 fs::create_dir_all(&sources_dir).expect("create Sources");
2115 let first = project_dir.join("First.xcstrings");
2116 let second = project_dir.join("Second.xcstrings");
2117 fs::write(
2118 &first,
2119 r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2120 )
2121 .expect("write first");
2122 fs::write(
2123 &second,
2124 r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2125 )
2126 .expect("write second");
2127
2128 let config_path = project_dir.join("langcodec.toml");
2129 fs::write(
2130 &config_path,
2131 r#"[openai]
2132model = "gpt-5.4"
2133
2134[annotate]
2135inputs = ["First.xcstrings", "Second.xcstrings"]
2136source_roots = ["Sources"]
2137source_lang = "en"
2138concurrency = 2
2139"#,
2140 )
2141 .expect("write config");
2142
2143 let loaded = load_config(Some(config_path.to_str().expect("config path")))
2144 .expect("load config")
2145 .expect("config present");
2146
2147 let runs = expand_annotate_invocations(
2148 &AnnotateOptions {
2149 input: None,
2150 source_roots: Vec::new(),
2151 output: None,
2152 source_lang: None,
2153 provider: None,
2154 model: None,
2155 concurrency: None,
2156 config: Some(config_path.to_string_lossy().to_string()),
2157 dry_run: false,
2158 check: false,
2159 ui_mode: UiMode::Plain,
2160 },
2161 Some(&loaded),
2162 )
2163 .expect("expand annotate invocations");
2164
2165 assert_eq!(runs.len(), 2);
2166 assert_eq!(runs[0].input, first.to_string_lossy().to_string());
2167 assert_eq!(runs[1].input, second.to_string_lossy().to_string());
2168 assert_eq!(
2169 runs[0].source_roots,
2170 vec![sources_dir.to_string_lossy().to_string()]
2171 );
2172 assert_eq!(
2173 runs[1].source_roots,
2174 vec![sources_dir.to_string_lossy().to_string()]
2175 );
2176 }
2177
2178 #[test]
2179 fn expand_annotate_invocations_expands_globbed_config_inputs() {
2180 let temp_dir = TempDir::new().expect("temp dir");
2181 let project_dir = temp_dir.path().join("project");
2182 let sources_dir = project_dir.join("Sources");
2183 let app_dir = project_dir.join("App").join("Resources");
2184 let module_dir = project_dir.join("Modules").join("Feature");
2185 fs::create_dir_all(&sources_dir).expect("create Sources");
2186 fs::create_dir_all(&app_dir).expect("create app dir");
2187 fs::create_dir_all(&module_dir).expect("create module dir");
2188
2189 let first = app_dir.join("Localizable.xcstrings");
2190 let second = module_dir.join("Localizable.xcstrings");
2191 fs::write(
2192 &first,
2193 r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2194 )
2195 .expect("write first");
2196 fs::write(
2197 &second,
2198 r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2199 )
2200 .expect("write second");
2201
2202 let config_path = project_dir.join("langcodec.toml");
2203 fs::write(
2204 &config_path,
2205 r#"[openai]
2206model = "gpt-5.4"
2207
2208[annotate]
2209inputs = ["*/**/Localizable.xcstrings"]
2210source_roots = ["Sources"]
2211"#,
2212 )
2213 .expect("write config");
2214
2215 let loaded = load_config(Some(config_path.to_str().expect("config path")))
2216 .expect("load config")
2217 .expect("config present");
2218
2219 let runs = expand_annotate_invocations(
2220 &AnnotateOptions {
2221 input: None,
2222 source_roots: Vec::new(),
2223 output: None,
2224 source_lang: None,
2225 provider: None,
2226 model: None,
2227 concurrency: None,
2228 config: Some(config_path.to_string_lossy().to_string()),
2229 dry_run: false,
2230 check: false,
2231 ui_mode: UiMode::Plain,
2232 },
2233 Some(&loaded),
2234 )
2235 .expect("expand annotate invocations");
2236
2237 let mut inputs = runs.into_iter().map(|run| run.input).collect::<Vec<_>>();
2238 inputs.sort();
2239
2240 let mut expected = vec![
2241 first.to_string_lossy().to_string(),
2242 second.to_string_lossy().to_string(),
2243 ];
2244 expected.sort();
2245
2246 assert_eq!(inputs, expected);
2247 }
2248
2249 #[test]
2250 fn expand_annotate_invocations_rejects_input_and_inputs_together() {
2251 let temp_dir = TempDir::new().expect("temp dir");
2252 let config_path = temp_dir.path().join("langcodec.toml");
2253 fs::write(
2254 &config_path,
2255 r#"[annotate]
2256input = "Localizable.xcstrings"
2257inputs = ["One.xcstrings", "Two.xcstrings"]
2258source_roots = ["Sources"]
2259"#,
2260 )
2261 .expect("write config");
2262
2263 let loaded = load_config(Some(config_path.to_str().expect("config path")))
2264 .expect("load config")
2265 .expect("config present");
2266
2267 let err = expand_annotate_invocations(
2268 &AnnotateOptions {
2269 input: None,
2270 source_roots: Vec::new(),
2271 output: None,
2272 source_lang: None,
2273 provider: None,
2274 model: None,
2275 concurrency: None,
2276 config: Some(config_path.to_string_lossy().to_string()),
2277 dry_run: false,
2278 check: false,
2279 ui_mode: UiMode::Plain,
2280 },
2281 Some(&loaded),
2282 )
2283 .expect_err("expected conflicting config to fail");
2284
2285 assert!(err.contains("annotate.input and annotate.inputs"));
2286 }
2287
2288 #[test]
2289 fn expand_annotate_invocations_rejects_shared_output_for_multiple_inputs() {
2290 let temp_dir = TempDir::new().expect("temp dir");
2291 let project_dir = temp_dir.path().join("project");
2292 let sources_dir = project_dir.join("Sources");
2293 fs::create_dir_all(&sources_dir).expect("create Sources");
2294 fs::write(
2295 project_dir.join("One.xcstrings"),
2296 r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2297 )
2298 .expect("write One");
2299 fs::write(
2300 project_dir.join("Two.xcstrings"),
2301 r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2302 )
2303 .expect("write Two");
2304
2305 let config_path = project_dir.join("langcodec.toml");
2306 fs::write(
2307 &config_path,
2308 r#"[openai]
2309model = "gpt-5.4"
2310
2311[annotate]
2312inputs = ["One.xcstrings", "Two.xcstrings"]
2313source_roots = ["Sources"]
2314output = "Annotated.xcstrings"
2315"#,
2316 )
2317 .expect("write config");
2318
2319 let loaded = load_config(Some(config_path.to_str().expect("config path")))
2320 .expect("load config")
2321 .expect("config present");
2322
2323 let err = expand_annotate_invocations(
2324 &AnnotateOptions {
2325 input: None,
2326 source_roots: Vec::new(),
2327 output: None,
2328 source_lang: None,
2329 provider: None,
2330 model: None,
2331 concurrency: None,
2332 config: Some(config_path.to_string_lossy().to_string()),
2333 dry_run: false,
2334 check: false,
2335 ui_mode: UiMode::Plain,
2336 },
2337 Some(&loaded),
2338 )
2339 .expect_err("expected multiple input/output conflict");
2340
2341 assert!(err.contains("annotate.inputs cannot be combined"));
2342 }
2343
2344 #[tokio::test]
2345 async fn mentra_backend_requests_files_tool() {
2346 let requests = Arc::new(Mutex::new(Vec::new()));
2347 let provider = RecordingProvider {
2348 requests: Arc::clone(&requests),
2349 };
2350 let runtime = Runtime::builder()
2351 .with_provider_instance(provider)
2352 .build()
2353 .expect("build runtime");
2354 let backend = MentraAnnotatorBackend::from_runtime(
2355 runtime,
2356 ModelInfo::new("test-model", BuiltinProvider::OpenAI),
2357 PathBuf::from("/tmp/project"),
2358 );
2359
2360 let response = backend
2361 .annotate(
2362 AnnotationRequest {
2363 key: "start".to_string(),
2364 source_lang: "en".to_string(),
2365 source_value: "Start".to_string(),
2366 existing_comment: None,
2367 source_roots: vec!["Sources".to_string()],
2368 },
2369 None,
2370 )
2371 .await
2372 .expect("annotate")
2373 .expect("response");
2374
2375 assert_eq!(response.comment, "A button label that starts the game.");
2376 let recorded = requests.lock().expect("requests lock");
2377 assert_eq!(recorded.len(), 1);
2378 let tool_names = recorded[0]
2379 .tools
2380 .iter()
2381 .map(|tool| tool.name.as_str())
2382 .collect::<Vec<_>>();
2383 assert!(tool_names.contains(&"files"));
2384 assert!(tool_names.contains(&"shell"));
2385 }
2386
2387 #[tokio::test]
2388 async fn old_tool_enabled_annotate_flow_recovers_from_malformed_tool_json_on_mentra_030() {
2389 let requests = Arc::new(Mutex::new(Vec::new()));
2390 let scripts = VecDeque::from([
2391 vec![
2392 ProviderEvent::MessageStarted {
2393 id: "msg-1".to_string(),
2394 model: "test-model".to_string(),
2395 role: Role::Assistant,
2396 },
2397 ProviderEvent::ContentBlockStarted {
2398 index: 0,
2399 kind: ContentBlockStart::ToolUse {
2400 id: "tool-1".to_string(),
2401 name: "files".to_string(),
2402 },
2403 },
2404 ProviderEvent::ContentBlockDelta {
2405 index: 0,
2406 delta: ContentBlockDelta::ToolUseInputJson(
2407 r#"{"path":"Sources/GameView.swift"#.to_string(),
2408 ),
2409 },
2410 ProviderEvent::ContentBlockStopped { index: 0 },
2411 ProviderEvent::MessageStopped,
2412 ],
2413 Response {
2414 id: "resp-2".to_string(),
2415 model: "test-model".to_string(),
2416 role: Role::Assistant,
2417 content: vec![ContentBlock::text(
2418 r#"{"comment":"A button label that starts the game.","confidence":"high"}"#,
2419 )],
2420 stop_reason: Some("end_turn".to_string()),
2421 usage: None,
2422 }
2423 .into_provider_events(),
2424 ]);
2425 let provider = ScriptedStreamingProvider {
2426 requests: Arc::clone(&requests),
2427 scripts: Arc::new(Mutex::new(scripts)),
2428 };
2429 let runtime = Runtime::builder()
2430 .with_provider_instance(provider)
2431 .build()
2432 .expect("build runtime");
2433 let mut agent = runtime
2434 .spawn_with_config(
2435 "annotate",
2436 ModelInfo::new("test-model", BuiltinProvider::OpenAI),
2437 build_agent_config(Path::new("/tmp/project")),
2438 )
2439 .expect("spawn agent");
2440 let request = AnnotationRequest {
2441 key: "start".to_string(),
2442 source_lang: "en".to_string(),
2443 source_value: "Start".to_string(),
2444 existing_comment: None,
2445 source_roots: vec!["Sources".to_string()],
2446 };
2447
2448 let response = agent
2449 .run(
2450 vec![ContentBlock::text(build_annotation_prompt(&request))],
2451 RunOptions {
2452 tool_budget: Some(DEFAULT_TOOL_BUDGET),
2453 ..RunOptions::default()
2454 },
2455 )
2456 .await
2457 .expect("run annotate");
2458 let parsed = parse_annotation_response(&response.text()).expect("parse annotation");
2459
2460 assert_eq!(parsed.comment, "A button label that starts the game.");
2461 let recorded = requests.lock().expect("requests lock");
2462 assert_eq!(recorded.len(), 2);
2463 assert!(
2464 recorded[1]
2465 .messages
2466 .iter()
2467 .flat_map(|message| message.content.iter())
2468 .any(|block| matches!(block, ContentBlock::Text { text } if text.contains("One or more tool calls could not be executed because their JSON arguments were invalid.")))
2469 );
2470 }
2471}