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