1use crate::{
2 ai::{ProviderKind, read_api_key, resolve_model, resolve_provider},
3 config::{LoadedConfig, load_config, resolve_config_relative_path},
4 tui::{
5 DashboardEvent, DashboardInit, DashboardItem, DashboardItemStatus, DashboardKind,
6 DashboardLogTone, PlainReporter, ResolvedUiMode, RunReporter, SummaryRow, TuiReporter,
7 UiMode, resolve_ui_mode_for_current_terminal,
8 },
9 validation::validate_language_code,
10};
11use async_trait::async_trait;
12use langcodec::{
13 Resource, Translation,
14 formats::{XcstringsFormat, xcstrings::Item},
15 traits::Parser,
16};
17use mentra::{
18 AgentConfig, ContentBlock, ModelInfo, Runtime,
19 agent::{AgentEvent, ToolProfile, WorkspaceConfig},
20 provider::ProviderRequestOptions,
21 runtime::RunOptions,
22};
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use std::{
26 collections::{BTreeMap, HashMap, VecDeque},
27 fs,
28 path::{Path, PathBuf},
29 sync::Arc,
30};
31use tokio::{
32 runtime::Builder,
33 sync::{Mutex as AsyncMutex, broadcast, mpsc},
34 task::JoinSet,
35};
36
37const DEFAULT_CONCURRENCY: usize = 4;
38const DEFAULT_TOOL_BUDGET: usize = 16;
39const ANNOTATION_SYSTEM_PROMPT: &str = "You write translator-facing comments for Xcode xcstrings 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\"}.";
40
41#[derive(Debug, Clone)]
42pub struct AnnotateOptions {
43 pub input: Option<String>,
44 pub source_roots: Vec<String>,
45 pub output: Option<String>,
46 pub source_lang: Option<String>,
47 pub provider: Option<String>,
48 pub model: Option<String>,
49 pub concurrency: Option<usize>,
50 pub config: Option<String>,
51 pub dry_run: bool,
52 pub check: bool,
53 pub ui_mode: UiMode,
54}
55
56#[derive(Debug, Clone)]
57struct ResolvedAnnotateOptions {
58 input: String,
59 output: String,
60 source_roots: Vec<String>,
61 source_lang: Option<String>,
62 provider: ProviderKind,
63 model: String,
64 concurrency: usize,
65 dry_run: bool,
66 check: bool,
67 workspace_root: PathBuf,
68 ui_mode: ResolvedUiMode,
69}
70
71#[derive(Debug, Clone)]
72struct AnnotationRequest {
73 key: String,
74 source_lang: String,
75 source_value: String,
76 existing_comment: Option<String>,
77 source_roots: Vec<String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
81struct AnnotationResponse {
82 comment: String,
83 confidence: String,
84}
85
86enum WorkerUpdate {
87 Started {
88 worker_id: usize,
89 key: String,
90 candidate_count: usize,
91 top_candidate: Option<String>,
92 },
93 ToolCall {
94 tone: DashboardLogTone,
95 message: String,
96 },
97 Finished {
98 worker_id: usize,
99 key: String,
100 result: Result<Option<AnnotationResponse>, String>,
101 },
102}
103
104#[async_trait]
105trait AnnotationBackend: Send + Sync {
106 async fn annotate(
107 &self,
108 request: AnnotationRequest,
109 event_tx: Option<mpsc::UnboundedSender<WorkerUpdate>>,
110 ) -> Result<Option<AnnotationResponse>, String>;
111}
112
113struct MentraAnnotatorBackend {
114 runtime: Arc<Runtime>,
115 model: ModelInfo,
116 workspace_root: PathBuf,
117}
118
119impl MentraAnnotatorBackend {
120 fn new(opts: &ResolvedAnnotateOptions) -> Result<Self, String> {
121 let api_key = read_api_key(&opts.provider)?;
122 let provider = opts.provider.builtin_provider();
123 let runtime = Runtime::builder()
124 .with_provider(provider, api_key)
125 .build()
126 .map_err(|e| format!("Failed to build Mentra runtime: {}", e))?;
127
128 Ok(Self {
129 runtime: Arc::new(runtime),
130 model: ModelInfo::new(opts.model.clone(), provider),
131 workspace_root: opts.workspace_root.clone(),
132 })
133 }
134
135 #[cfg(test)]
136 fn from_runtime(runtime: Runtime, model: ModelInfo, workspace_root: PathBuf) -> Self {
137 Self {
138 runtime: Arc::new(runtime),
139 model,
140 workspace_root,
141 }
142 }
143}
144
145#[async_trait]
146impl AnnotationBackend for MentraAnnotatorBackend {
147 async fn annotate(
148 &self,
149 request: AnnotationRequest,
150 event_tx: Option<mpsc::UnboundedSender<WorkerUpdate>>,
151 ) -> Result<Option<AnnotationResponse>, String> {
152 let config = build_agent_config(&self.workspace_root);
153 let mut agent = self
154 .runtime
155 .spawn_with_config("annotate", self.model.clone(), config)
156 .map_err(|e| format!("Failed to spawn Mentra agent: {}", e))?;
157 let tool_logger =
158 spawn_tool_call_logger(agent.subscribe_events(), request.key.clone(), event_tx);
159
160 let response = agent
161 .run(
162 vec![ContentBlock::text(build_annotation_prompt(&request))],
163 RunOptions {
164 tool_budget: Some(DEFAULT_TOOL_BUDGET),
165 ..RunOptions::default()
166 },
167 )
168 .await;
169 tool_logger.abort();
170 let _ = tool_logger.await;
171
172 let response = response.map_err(|e| format!("Annotation agent failed: {}", e))?;
173
174 parse_annotation_response(&response.text()).map(Some)
175 }
176}
177
178pub fn run_annotate_command(opts: AnnotateOptions) -> Result<(), String> {
179 let config = load_config(opts.config.as_deref())?;
180 let runs = expand_annotate_invocations(&opts, config.as_ref())?;
181
182 for resolved in runs {
183 let backend: Arc<dyn AnnotationBackend> = Arc::new(MentraAnnotatorBackend::new(&resolved)?);
184 run_annotate_with_backend(resolved, backend)?;
185 }
186
187 Ok(())
188}
189
190fn run_annotate_with_backend(
191 opts: ResolvedAnnotateOptions,
192 backend: Arc<dyn AnnotationBackend>,
193) -> Result<(), String> {
194 let mut catalog = XcstringsFormat::read_from(&opts.input)
195 .map_err(|e| format!("Failed to read '{}': {}", opts.input, e))?;
196 let resources = Vec::<Resource>::try_from(catalog.clone())
197 .map_err(|e| format!("Failed to decode xcstrings '{}': {}", opts.input, e))?;
198
199 let source_lang = opts
200 .source_lang
201 .clone()
202 .unwrap_or_else(|| catalog.source_language.clone());
203 validate_language_code(&source_lang)?;
204
205 let source_values = source_value_map(&resources, &source_lang);
206 let requests = build_annotation_requests(
207 &catalog,
208 &source_lang,
209 &source_values,
210 &opts.source_roots,
211 &opts.workspace_root,
212 )?;
213
214 if requests.is_empty() {
215 println!("No entries require annotation updates.");
216 return Ok(());
217 }
218
219 let mut reporter = create_annotate_reporter(&opts, &source_lang, &requests)?;
220 reporter.emit(DashboardEvent::Log {
221 tone: DashboardLogTone::Info,
222 message: format!("Annotating {}", opts.input),
223 });
224 reporter.emit(DashboardEvent::Log {
225 tone: DashboardLogTone::Info,
226 message: format!(
227 "Generating translator comments for {} entr{} with {} worker(s)...",
228 requests.len(),
229 if requests.len() == 1 { "y" } else { "ies" },
230 opts.concurrency
231 ),
232 });
233 let results = annotate_requests(requests, backend, opts.concurrency, &mut *reporter);
234 let results = results?;
235 let mut changed = 0usize;
236 let mut unmatched = 0usize;
237
238 let mut keys = catalog.strings.keys().cloned().collect::<Vec<_>>();
239 keys.sort();
240 for key in keys {
241 let Some(item) = catalog.strings.get_mut(&key) else {
242 continue;
243 };
244 if should_preserve_manual_comment(item) {
245 continue;
246 }
247
248 match results.get(&key) {
249 Some(Some(annotation)) => {
250 if item.comment.as_deref() != Some(annotation.comment.as_str())
251 || item.is_comment_auto_generated != Some(true)
252 {
253 item.comment = Some(annotation.comment.clone());
254 item.is_comment_auto_generated = Some(true);
255 changed += 1;
256 }
257 }
258 Some(None) => unmatched += 1,
259 None => {}
260 }
261 }
262
263 if opts.check && changed > 0 {
264 reporter.emit(DashboardEvent::Log {
265 tone: DashboardLogTone::Warning,
266 message: format!("would change: {}", opts.output),
267 });
268 reporter.finish()?;
269 println!("would change: {}", opts.output);
270 return Err(format!("would change: {}", opts.output));
271 }
272
273 if opts.dry_run {
274 reporter.emit(DashboardEvent::Log {
275 tone: DashboardLogTone::Info,
276 message: format!(
277 "DRY-RUN: would update {} comment(s) in {}",
278 changed, opts.output
279 ),
280 });
281 reporter.finish()?;
282 println!(
283 "DRY-RUN: would update {} comment(s) in {}",
284 changed, opts.output
285 );
286 if unmatched > 0 {
287 println!("Skipped {} entry(s) without generated comments", unmatched);
288 }
289 return Ok(());
290 }
291
292 if changed == 0 {
293 reporter.emit(DashboardEvent::Log {
294 tone: DashboardLogTone::Success,
295 message: "No comment updates were necessary.".to_string(),
296 });
297 reporter.finish()?;
298 println!("No comment updates were necessary.");
299 if unmatched > 0 {
300 println!("Skipped {} entry(s) without generated comments", unmatched);
301 }
302 return Ok(());
303 }
304
305 reporter.emit(DashboardEvent::Log {
306 tone: DashboardLogTone::Info,
307 message: format!("Writing {}", opts.output),
308 });
309 if let Err(err) = catalog.write_to(&opts.output) {
310 let err = format!("Failed to write '{}': {}", opts.output, err);
311 reporter.emit(DashboardEvent::Log {
312 tone: DashboardLogTone::Error,
313 message: err.clone(),
314 });
315 reporter.finish()?;
316 return Err(err);
317 }
318 reporter.emit(DashboardEvent::Log {
319 tone: DashboardLogTone::Success,
320 message: format!("Updated {} comment(s) in {}", changed, opts.output),
321 });
322 reporter.finish()?;
323
324 println!("Updated {} comment(s) in {}", changed, opts.output);
325 if unmatched > 0 {
326 println!("Skipped {} entry(s) without generated comments", unmatched);
327 }
328 Ok(())
329}
330
331fn expand_annotate_invocations(
332 opts: &AnnotateOptions,
333 config: Option<&LoadedConfig>,
334) -> Result<Vec<ResolvedAnnotateOptions>, String> {
335 let cfg = config.map(|item| &item.data.annotate);
336 let config_dir = config.and_then(LoadedConfig::config_dir);
337
338 if cfg
339 .and_then(|item| item.input.as_ref())
340 .is_some_and(|_| cfg.and_then(|item| item.inputs.as_ref()).is_some())
341 {
342 return Err("Config annotate.input and annotate.inputs cannot both be set".to_string());
343 }
344
345 let inputs = resolve_config_inputs(opts, cfg, config_dir)?;
346 if inputs.is_empty() {
347 return Err(
348 "--input is required unless annotate.input or annotate.inputs is set in langcodec.toml"
349 .to_string(),
350 );
351 }
352
353 let output = if let Some(output) = &opts.output {
354 Some(output.clone())
355 } else {
356 cfg.and_then(|item| item.output.clone())
357 .map(|path| resolve_config_relative_path(config_dir, &path))
358 };
359
360 if inputs.len() > 1 && output.is_some() {
361 return Err(
362 "annotate.inputs cannot be combined with annotate.output or CLI --output; use in-place annotation for multiple inputs"
363 .to_string(),
364 );
365 }
366
367 inputs
368 .into_iter()
369 .map(|input| {
370 resolve_annotate_options(
371 &AnnotateOptions {
372 input: Some(input),
373 source_roots: opts.source_roots.clone(),
374 output: output.clone(),
375 source_lang: opts.source_lang.clone(),
376 provider: opts.provider.clone(),
377 model: opts.model.clone(),
378 concurrency: opts.concurrency,
379 config: opts.config.clone(),
380 dry_run: opts.dry_run,
381 check: opts.check,
382 ui_mode: opts.ui_mode,
383 },
384 config,
385 )
386 })
387 .collect()
388}
389
390fn resolve_config_inputs(
391 opts: &AnnotateOptions,
392 cfg: Option<&crate::config::AnnotateConfig>,
393 config_dir: Option<&Path>,
394) -> Result<Vec<String>, String> {
395 if let Some(input) = &opts.input {
396 return Ok(vec![input.clone()]);
397 }
398
399 if let Some(input) = cfg.and_then(|item| item.input.as_ref()) {
400 return Ok(vec![resolve_config_relative_path(config_dir, input)]);
401 }
402
403 if let Some(inputs) = cfg.and_then(|item| item.inputs.as_ref()) {
404 return Ok(inputs
405 .iter()
406 .map(|input| resolve_config_relative_path(config_dir, input))
407 .collect());
408 }
409
410 Ok(Vec::new())
411}
412
413fn resolve_annotate_options(
414 opts: &AnnotateOptions,
415 config: Option<&LoadedConfig>,
416) -> Result<ResolvedAnnotateOptions, String> {
417 let cfg = config.map(|item| &item.data.annotate);
418 let config_dir = config.and_then(LoadedConfig::config_dir);
419 let cwd = std::env::current_dir()
420 .map_err(|e| format!("Failed to determine current directory: {}", e))?;
421
422 let input = if let Some(input) = &opts.input {
423 absolutize_path(input, &cwd)
424 } else if let Some(input) = cfg.and_then(|item| item.input.as_deref()) {
425 absolutize_path(&resolve_config_relative_path(config_dir, input), &cwd)
426 } else {
427 return Err(
428 "--input is required unless annotate.input or annotate.inputs is set in langcodec.toml"
429 .to_string(),
430 );
431 };
432
433 let source_roots = if !opts.source_roots.is_empty() {
434 opts.source_roots
435 .iter()
436 .map(|path| absolutize_path(path, &cwd))
437 .collect::<Vec<_>>()
438 } else if let Some(roots) = cfg.and_then(|item| item.source_roots.as_ref()) {
439 roots
440 .iter()
441 .map(|path| absolutize_path(&resolve_config_relative_path(config_dir, path), &cwd))
442 .collect::<Vec<_>>()
443 } else {
444 Vec::new()
445 };
446 if source_roots.is_empty() {
447 return Err(
448 "--source-root is required unless annotate.source_roots is set in langcodec.toml"
449 .to_string(),
450 );
451 }
452 for root in &source_roots {
453 let path = Path::new(root);
454 if !path.is_dir() {
455 return Err(format!(
456 "Source root does not exist or is not a directory: {}",
457 root
458 ));
459 }
460 }
461
462 let output = if let Some(output) = &opts.output {
463 absolutize_path(output, &cwd)
464 } else if let Some(output) = cfg.and_then(|item| item.output.as_deref()) {
465 absolutize_path(&resolve_config_relative_path(config_dir, output), &cwd)
466 } else {
467 input.clone()
468 };
469
470 let concurrency = opts
471 .concurrency
472 .or_else(|| cfg.and_then(|item| item.concurrency))
473 .unwrap_or(DEFAULT_CONCURRENCY);
474 if concurrency == 0 {
475 return Err("Concurrency must be greater than zero".to_string());
476 }
477
478 let provider = resolve_provider(
479 opts.provider.as_deref(),
480 config.map(|item| &item.data),
481 None,
482 )?;
483 let model = resolve_model(
484 opts.model.as_deref(),
485 config.map(|item| &item.data),
486 &provider,
487 None,
488 )?;
489
490 let source_lang = opts
491 .source_lang
492 .clone()
493 .or_else(|| cfg.and_then(|item| item.source_lang.clone()));
494 if let Some(lang) = &source_lang {
495 validate_language_code(lang)?;
496 }
497 let ui_mode = resolve_ui_mode_for_current_terminal(opts.ui_mode)?;
498
499 let workspace_root = derive_workspace_root(&input, &source_roots, &cwd);
500
501 Ok(ResolvedAnnotateOptions {
502 input,
503 output,
504 source_roots,
505 source_lang,
506 provider,
507 model,
508 concurrency,
509 dry_run: opts.dry_run,
510 check: opts.check,
511 workspace_root,
512 ui_mode,
513 })
514}
515
516fn annotate_requests(
517 requests: Vec<AnnotationRequest>,
518 backend: Arc<dyn AnnotationBackend>,
519 concurrency: usize,
520 reporter: &mut dyn RunReporter,
521) -> Result<BTreeMap<String, Option<AnnotationResponse>>, String> {
522 let runtime = Builder::new_multi_thread()
523 .enable_all()
524 .build()
525 .map_err(|e| format!("Failed to start async runtime: {}", e))?;
526
527 let total = requests.len();
528 runtime.block_on(async {
529 let worker_count = concurrency.min(total).max(1);
530 let queue = Arc::new(AsyncMutex::new(VecDeque::from(requests)));
531 let (tx, mut rx) = mpsc::unbounded_channel::<WorkerUpdate>();
532 let mut set = JoinSet::new();
533 for worker_id in 1..=worker_count {
534 let backend = Arc::clone(&backend);
535 let queue = Arc::clone(&queue);
536 let tx = tx.clone();
537 set.spawn(async move {
538 loop {
539 let request = {
540 let mut queue = queue.lock().await;
541 queue.pop_front()
542 };
543
544 let Some(request) = request else {
545 break;
546 };
547
548 let key = request.key.clone();
549 let _ = tx.send(WorkerUpdate::Started {
550 worker_id,
551 key: key.clone(),
552 candidate_count: 0,
553 top_candidate: None,
554 });
555 let result = backend.annotate(request, Some(tx.clone())).await;
556 let _ = tx.send(WorkerUpdate::Finished {
557 worker_id,
558 key,
559 result,
560 });
561 }
562
563 Ok::<(), String>(())
564 });
565 }
566 drop(tx);
567
568 let mut results = BTreeMap::new();
569 let mut generated = 0usize;
570 let mut unmatched = 0usize;
571 let mut first_error = None;
572
573 while let Some(update) = rx.recv().await {
574 match update {
575 WorkerUpdate::Started {
576 worker_id,
577 key,
578 candidate_count,
579 top_candidate,
580 } => {
581 reporter.emit(DashboardEvent::Log {
582 tone: DashboardLogTone::Info,
583 message: annotate_worker_started_message(
584 worker_id,
585 &key,
586 candidate_count,
587 top_candidate.as_deref(),
588 ),
589 });
590 reporter.emit(DashboardEvent::UpdateItem {
591 id: key,
592 status: Some(DashboardItemStatus::Running),
593 subtitle: None,
594 source_text: None,
595 output_text: None,
596 note_text: None,
597 error_text: None,
598 extra_rows: None,
599 });
600 }
601 WorkerUpdate::ToolCall { tone, message } => {
602 reporter.emit(DashboardEvent::Log { tone, message });
603 }
604 WorkerUpdate::Finished {
605 worker_id,
606 key,
607 result,
608 } => {
609 match result {
610 Ok(annotation) => {
611 if annotation.is_some() {
612 generated += 1;
613 } else {
614 unmatched += 1;
615 }
616 let status = if annotation.is_some() {
617 DashboardItemStatus::Succeeded
618 } else {
619 DashboardItemStatus::Skipped
620 };
621 reporter.emit(DashboardEvent::Log {
622 tone: if annotation.is_some() {
623 DashboardLogTone::Success
624 } else {
625 DashboardLogTone::Warning
626 },
627 message: annotate_worker_finished_message(
628 worker_id,
629 &key,
630 &annotation,
631 ),
632 });
633 reporter.emit(DashboardEvent::UpdateItem {
634 id: key.clone(),
635 status: Some(status),
636 subtitle: None,
637 source_text: None,
638 output_text: annotation.as_ref().map(|item| item.comment.clone()),
639 note_text: None,
640 error_text: None,
641 extra_rows: annotation.as_ref().map(|item| {
642 vec![SummaryRow::new("Confidence", item.confidence.clone())]
643 }),
644 });
645 results.insert(key, annotation);
646 }
647 Err(err) => {
648 reporter.emit(DashboardEvent::Log {
649 tone: DashboardLogTone::Error,
650 message: format!(
651 "Worker {} finished key={} result=failed",
652 worker_id, key
653 ),
654 });
655 reporter.emit(DashboardEvent::UpdateItem {
656 id: key,
657 status: Some(DashboardItemStatus::Failed),
658 subtitle: None,
659 source_text: None,
660 output_text: None,
661 note_text: None,
662 error_text: Some(err.clone()),
663 extra_rows: None,
664 });
665 if first_error.is_none() {
666 first_error = Some(err);
667 }
668 }
669 }
670 reporter.emit(DashboardEvent::SummaryRows {
671 rows: annotate_summary_rows(total, generated, unmatched),
672 });
673 }
674 }
675 }
676
677 while let Some(joined) = set.join_next().await {
678 match joined {
679 Ok(Ok(())) => {}
680 Ok(Err(err)) => {
681 if first_error.is_none() {
682 first_error = Some(err);
683 }
684 }
685 Err(err) => {
686 if first_error.is_none() {
687 first_error = Some(format!("Annotation task failed: {}", err));
688 }
689 }
690 }
691 }
692
693 if let Some(err) = first_error {
694 return Err(err);
695 }
696
697 Ok(results)
698 })
699}
700
701fn build_annotation_requests(
702 catalog: &XcstringsFormat,
703 source_lang: &str,
704 source_values: &HashMap<String, String>,
705 source_roots: &[String],
706 workspace_root: &Path,
707) -> Result<Vec<AnnotationRequest>, String> {
708 let mut keys = catalog.strings.keys().cloned().collect::<Vec<_>>();
709 keys.sort();
710
711 let mut requests = Vec::new();
712 for key in keys {
713 let Some(item) = catalog.strings.get(&key) else {
714 continue;
715 };
716 if should_preserve_manual_comment(item) {
717 continue;
718 }
719
720 let source_value = source_values
721 .get(&key)
722 .cloned()
723 .unwrap_or_else(|| key.clone());
724
725 requests.push(AnnotationRequest {
726 key,
727 source_lang: source_lang.to_string(),
728 source_value,
729 existing_comment: item.comment.clone(),
730 source_roots: source_roots
731 .iter()
732 .map(|root| display_path(workspace_root, Path::new(root)))
733 .collect(),
734 });
735 }
736
737 Ok(requests)
738}
739
740fn should_preserve_manual_comment(item: &Item) -> bool {
741 item.comment.is_some() && item.is_comment_auto_generated != Some(true)
742}
743
744fn create_annotate_reporter(
745 opts: &ResolvedAnnotateOptions,
746 source_lang: &str,
747 requests: &[AnnotationRequest],
748) -> Result<Box<dyn RunReporter>, String> {
749 let init = DashboardInit {
750 kind: DashboardKind::Annotate,
751 title: Path::new(&opts.input)
752 .file_name()
753 .and_then(|name| name.to_str())
754 .unwrap_or(opts.input.as_str())
755 .to_string(),
756 metadata: annotate_metadata_rows(opts, source_lang),
757 summary_rows: annotate_summary_rows(requests.len(), 0, 0),
758 items: requests.iter().map(annotate_dashboard_item).collect(),
759 };
760 match opts.ui_mode {
761 ResolvedUiMode::Plain => Ok(Box::new(PlainReporter::new(init))),
762 ResolvedUiMode::Tui => Ok(Box::new(TuiReporter::new(init)?)),
763 }
764}
765
766fn annotate_metadata_rows(opts: &ResolvedAnnotateOptions, source_lang: &str) -> Vec<SummaryRow> {
767 let mut rows = vec![
768 SummaryRow::new(
769 "Provider",
770 format!("{}:{}", opts.provider.display_name(), opts.model),
771 ),
772 SummaryRow::new("Input", opts.input.clone()),
773 SummaryRow::new("Output", opts.output.clone()),
774 SummaryRow::new("Source language", source_lang.to_string()),
775 SummaryRow::new("Concurrency", opts.concurrency.to_string()),
776 ];
777 if opts.dry_run {
778 rows.push(SummaryRow::new("Mode", "dry-run"));
779 }
780 if opts.check {
781 rows.push(SummaryRow::new("Check", "enabled"));
782 }
783 rows
784}
785
786fn annotate_summary_rows(total: usize, generated: usize, unmatched: usize) -> Vec<SummaryRow> {
787 vec![
788 SummaryRow::new("Total", total.to_string()),
789 SummaryRow::new("Generated", generated.to_string()),
790 SummaryRow::new("Skipped", unmatched.to_string()),
791 ]
792}
793
794fn annotate_dashboard_item(request: &AnnotationRequest) -> DashboardItem {
795 let mut item = DashboardItem::new(
796 request.key.clone(),
797 request.key.clone(),
798 request.source_lang.clone(),
799 DashboardItemStatus::Queued,
800 );
801 item.source_text = Some(request.source_value.clone());
802 item.note_text = request.existing_comment.clone();
803 item
804}
805
806fn annotate_worker_started_message(
807 worker_id: usize,
808 key: &str,
809 candidate_count: usize,
810 top_candidate: Option<&str>,
811) -> String {
812 let mut message = format!(
813 "Worker {} started key={} shortlist={}",
814 worker_id, key, candidate_count
815 );
816 if let Some(path) = top_candidate {
817 message.push_str(" top=");
818 message.push_str(path);
819 }
820 message
821}
822
823fn annotate_worker_finished_message(
824 worker_id: usize,
825 key: &str,
826 result: &Option<AnnotationResponse>,
827) -> String {
828 let status = if result.is_some() {
829 "generated"
830 } else {
831 "skipped"
832 };
833 format!(
834 "Worker {} finished key={} result={}",
835 worker_id, key, status
836 )
837}
838
839fn source_value_map(resources: &[Resource], source_lang: &str) -> HashMap<String, String> {
840 resources
841 .iter()
842 .find(|resource| lang_matches(&resource.metadata.language, source_lang))
843 .map(|resource| {
844 resource
845 .entries
846 .iter()
847 .map(|entry| {
848 (
849 entry.id.clone(),
850 translation_to_text(&entry.value, &entry.id),
851 )
852 })
853 .collect()
854 })
855 .unwrap_or_default()
856}
857
858fn translation_to_text(value: &Translation, fallback_key: &str) -> String {
859 match value {
860 Translation::Empty => fallback_key.to_string(),
861 Translation::Singular(text) => text.clone(),
862 Translation::Plural(plural) => plural
863 .forms
864 .values()
865 .next()
866 .cloned()
867 .unwrap_or_else(|| fallback_key.to_string()),
868 }
869}
870
871fn build_agent_config(workspace_root: &Path) -> AgentConfig {
872 AgentConfig {
873 system: Some(ANNOTATION_SYSTEM_PROMPT.to_string()),
874 temperature: Some(0.2),
875 max_output_tokens: Some(512),
876 tool_profile: ToolProfile::only(["files", "shell"]),
877 provider_request_options: ProviderRequestOptions {
878 openai: mentra::provider::OpenAIRequestOptions {
879 parallel_tool_calls: Some(false),
880 },
881 ..ProviderRequestOptions::default()
882 },
883 workspace: WorkspaceConfig {
884 base_dir: workspace_root.to_path_buf(),
885 auto_route_shell: false,
886 },
887 ..AgentConfig::default()
888 }
889}
890
891fn build_annotation_prompt(request: &AnnotationRequest) -> String {
892 let mut prompt = format!(
893 "Write one translator-facing comment for this xcstrings entry.\n\nKey: {}\nSource language: {}\nSource value: {}\n",
894 request.key, request.source_lang, request.source_value
895 );
896
897 if let Some(existing_comment) = &request.existing_comment {
898 prompt.push_str("\nExisting auto-generated comment:\n");
899 prompt.push_str(existing_comment);
900 prompt.push('\n');
901 }
902
903 prompt.push_str("\nSource roots you may inspect with the files tool:\n");
904 for root in &request.source_roots {
905 prompt.push_str("- ");
906 prompt.push_str(root);
907 prompt.push('\n');
908 }
909
910 prompt.push_str(
911 "\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",
912 );
913
914 prompt.push_str(
915 "\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",
916 );
917 prompt
918}
919
920fn spawn_tool_call_logger(
921 mut events: broadcast::Receiver<AgentEvent>,
922 key: String,
923 event_tx: Option<mpsc::UnboundedSender<WorkerUpdate>>,
924) -> tokio::task::JoinHandle<()> {
925 tokio::spawn(async move {
926 loop {
927 match events.recv().await {
928 Ok(AgentEvent::ToolExecutionStarted { call }) => {
929 if let Some(tx) = &event_tx {
930 let _ = tx.send(WorkerUpdate::ToolCall {
931 tone: DashboardLogTone::Info,
932 message: format!(
933 "Tool call key={} tool={} input={}",
934 key,
935 call.name,
936 compact_tool_input(&call.input)
937 ),
938 });
939 }
940 }
941 Ok(AgentEvent::ToolExecutionFinished { result }) => {
942 let status = match result {
943 ContentBlock::ToolResult { is_error, .. } if is_error => "error",
944 ContentBlock::ToolResult { .. } => "ok",
945 _ => "unknown",
946 };
947 if let Some(tx) = &event_tx {
948 let tone = if status == "error" {
949 DashboardLogTone::Error
950 } else {
951 DashboardLogTone::Success
952 };
953 let _ = tx.send(WorkerUpdate::ToolCall {
954 tone,
955 message: format!("Tool result key={} status={}", key, status),
956 });
957 }
958 }
959 Ok(_) => {}
960 Err(broadcast::error::RecvError::Closed) => break,
961 Err(broadcast::error::RecvError::Lagged(_)) => continue,
962 }
963 }
964 })
965}
966
967fn compact_tool_input(input: &Value) -> String {
968 const MAX_TOOL_INPUT_CHARS: usize = 180;
969
970 let rendered = serde_json::to_string(input).unwrap_or_else(|_| "<unserializable>".to_string());
971 let mut preview = rendered
972 .chars()
973 .take(MAX_TOOL_INPUT_CHARS)
974 .collect::<String>();
975 if rendered.chars().count() > MAX_TOOL_INPUT_CHARS {
976 preview.push_str("...");
977 }
978 preview
979}
980
981fn parse_annotation_response(text: &str) -> Result<AnnotationResponse, String> {
982 let trimmed = text.trim();
983 if trimmed.is_empty() {
984 return Err("Model returned an empty annotation response".to_string());
985 }
986
987 if let Ok(payload) = serde_json::from_str::<AnnotationResponse>(trimmed) {
988 return validate_annotation_response(payload);
989 }
990
991 if let Some(json_body) = extract_json_body(trimmed)
992 && let Ok(payload) = serde_json::from_str::<AnnotationResponse>(&json_body)
993 {
994 return validate_annotation_response(payload);
995 }
996
997 Err(format!(
998 "Model response was not valid annotation JSON: {}",
999 trimmed
1000 ))
1001}
1002
1003fn validate_annotation_response(payload: AnnotationResponse) -> Result<AnnotationResponse, String> {
1004 if payload.comment.trim().is_empty() {
1005 return Err("Model returned an empty annotation comment".to_string());
1006 }
1007 Ok(payload)
1008}
1009
1010fn extract_json_body(text: &str) -> Option<String> {
1011 let fenced = text
1012 .strip_prefix("```json")
1013 .or_else(|| text.strip_prefix("```"))
1014 .map(str::trim_start)?;
1015 let unfenced = fenced.strip_suffix("```")?.trim();
1016 Some(unfenced.to_string())
1017}
1018
1019fn absolutize_path(path: &str, cwd: &Path) -> String {
1020 let candidate = Path::new(path);
1021 if candidate.is_absolute() {
1022 candidate.to_string_lossy().to_string()
1023 } else {
1024 cwd.join(candidate).to_string_lossy().to_string()
1025 }
1026}
1027
1028fn derive_workspace_root(input: &str, source_roots: &[String], fallback: &Path) -> PathBuf {
1029 let mut candidates = Vec::new();
1030 candidates.push(path_root_candidate(Path::new(input)));
1031 for root in source_roots {
1032 candidates.push(path_root_candidate(Path::new(root)));
1033 }
1034
1035 common_ancestor(candidates.into_iter().flatten().collect::<Vec<_>>())
1036 .unwrap_or_else(|| fallback.to_path_buf())
1037}
1038
1039fn path_root_candidate(path: &Path) -> Option<PathBuf> {
1040 let absolute = fs::canonicalize(path).ok().or_else(|| {
1041 if path.is_absolute() {
1042 Some(path.to_path_buf())
1043 } else {
1044 None
1045 }
1046 })?;
1047
1048 if absolute.is_dir() {
1049 Some(absolute)
1050 } else {
1051 absolute.parent().map(Path::to_path_buf)
1052 }
1053}
1054
1055fn common_ancestor(paths: Vec<PathBuf>) -> Option<PathBuf> {
1056 let mut iter = paths.into_iter();
1057 let first = iter.next()?;
1058 let mut current = first;
1059
1060 for path in iter {
1061 let mut next = current.clone();
1062 while !path.starts_with(&next) {
1063 if !next.pop() {
1064 return None;
1065 }
1066 }
1067 current = next;
1068 }
1069
1070 Some(current)
1071}
1072
1073fn display_path(workspace_root: &Path, path: &Path) -> String {
1074 path.strip_prefix(workspace_root)
1075 .map(|relative| relative.to_string_lossy().to_string())
1076 .unwrap_or_else(|_| path.to_string_lossy().to_string())
1077}
1078
1079fn lang_matches(left: &str, right: &str) -> bool {
1080 normalize_lang(left) == normalize_lang(right)
1081}
1082
1083fn normalize_lang(lang: &str) -> String {
1084 lang.trim().replace('_', "-").to_ascii_lowercase()
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089 use super::*;
1090 use mentra::{
1091 BuiltinProvider, ModelInfo, ProviderDescriptor,
1092 provider::{
1093 ContentBlockDelta, ContentBlockStart, Provider, ProviderEvent, ProviderEventStream,
1094 Request, Response, Role, provider_event_stream_from_response,
1095 },
1096 runtime::RunOptions,
1097 };
1098 use std::sync::{Arc, Mutex};
1099 use tempfile::TempDir;
1100
1101 struct FakeBackend {
1102 responses: HashMap<String, Option<AnnotationResponse>>,
1103 }
1104
1105 #[async_trait]
1106 impl AnnotationBackend for FakeBackend {
1107 async fn annotate(
1108 &self,
1109 request: AnnotationRequest,
1110 _event_tx: Option<mpsc::UnboundedSender<WorkerUpdate>>,
1111 ) -> Result<Option<AnnotationResponse>, String> {
1112 Ok(self.responses.get(&request.key).cloned().flatten())
1113 }
1114 }
1115
1116 struct RuntimeHoldingBackend {
1117 _runtime: Arc<tokio::runtime::Runtime>,
1118 }
1119
1120 #[async_trait]
1121 impl AnnotationBackend for RuntimeHoldingBackend {
1122 async fn annotate(
1123 &self,
1124 _request: AnnotationRequest,
1125 _event_tx: Option<mpsc::UnboundedSender<WorkerUpdate>>,
1126 ) -> Result<Option<AnnotationResponse>, String> {
1127 Ok(Some(AnnotationResponse {
1128 comment: "Generated comment".to_string(),
1129 confidence: "high".to_string(),
1130 }))
1131 }
1132 }
1133
1134 struct RecordingProvider {
1135 requests: Arc<Mutex<Vec<Request<'static>>>>,
1136 }
1137
1138 struct ScriptedStreamingProvider {
1139 requests: Arc<Mutex<Vec<Request<'static>>>>,
1140 scripts: Arc<Mutex<VecDeque<Vec<ProviderEvent>>>>,
1141 }
1142
1143 #[async_trait]
1144 impl Provider for RecordingProvider {
1145 fn descriptor(&self) -> ProviderDescriptor {
1146 ProviderDescriptor::new(BuiltinProvider::OpenAI)
1147 }
1148
1149 async fn list_models(&self) -> Result<Vec<ModelInfo>, mentra::provider::ProviderError> {
1150 Ok(vec![ModelInfo::new("test-model", BuiltinProvider::OpenAI)])
1151 }
1152
1153 async fn stream(
1154 &self,
1155 request: Request<'_>,
1156 ) -> Result<ProviderEventStream, mentra::provider::ProviderError> {
1157 self.requests
1158 .lock()
1159 .expect("requests lock")
1160 .push(request.clone().into_owned());
1161 Ok(provider_event_stream_from_response(Response {
1162 id: "resp-1".to_string(),
1163 model: request.model.to_string(),
1164 role: Role::Assistant,
1165 content: vec![ContentBlock::text(
1166 r#"{"comment":"A button label that starts the game.","confidence":"high"}"#,
1167 )],
1168 stop_reason: Some("end_turn".to_string()),
1169 usage: None,
1170 }))
1171 }
1172 }
1173
1174 #[async_trait]
1175 impl Provider for ScriptedStreamingProvider {
1176 fn descriptor(&self) -> ProviderDescriptor {
1177 ProviderDescriptor::new(BuiltinProvider::OpenAI)
1178 }
1179
1180 async fn list_models(&self) -> Result<Vec<ModelInfo>, mentra::provider::ProviderError> {
1181 Ok(vec![ModelInfo::new("test-model", BuiltinProvider::OpenAI)])
1182 }
1183
1184 async fn stream(
1185 &self,
1186 request: Request<'_>,
1187 ) -> Result<ProviderEventStream, mentra::provider::ProviderError> {
1188 self.requests
1189 .lock()
1190 .expect("requests lock")
1191 .push(request.clone().into_owned());
1192 let script = self
1193 .scripts
1194 .lock()
1195 .expect("scripts lock")
1196 .pop_front()
1197 .expect("missing scripted response");
1198
1199 let (tx, rx) = mpsc::unbounded_channel();
1200 for event in script {
1201 tx.send(Ok(event)).expect("send provider event");
1202 }
1203 Ok(rx)
1204 }
1205 }
1206
1207 #[test]
1208 fn build_agent_config_limits_tools_to_files() {
1209 let config = build_agent_config(Path::new("/tmp/project"));
1210 assert!(config.tool_profile.allows("files"));
1211 assert!(config.tool_profile.allows("shell"));
1212 assert!(!config.tool_profile.allows("task"));
1213 }
1214
1215 #[test]
1216 fn parse_annotation_response_accepts_fenced_json() {
1217 let parsed = parse_annotation_response(
1218 "```json\n{\"comment\":\"Dialog title for room exit confirmation.\",\"confidence\":\"medium\"}\n```",
1219 )
1220 .expect("parse response");
1221 assert_eq!(
1222 parsed,
1223 AnnotationResponse {
1224 comment: "Dialog title for room exit confirmation.".to_string(),
1225 confidence: "medium".to_string(),
1226 }
1227 );
1228 }
1229
1230 #[test]
1231 fn run_annotate_updates_missing_and_auto_generated_comments_only() {
1232 let temp_dir = TempDir::new().expect("temp dir");
1233 let input = temp_dir.path().join("Localizable.xcstrings");
1234 let source_root = temp_dir.path().join("Sources");
1235 fs::create_dir_all(&source_root).expect("create root");
1236 fs::write(
1237 source_root.join("GameView.swift"),
1238 r#"Text("Start", bundle: .module)"#,
1239 )
1240 .expect("write swift");
1241 fs::write(
1242 &input,
1243 r#"{
1244 "sourceLanguage": "en",
1245 "version": "1.0",
1246 "strings": {
1247 "start": {
1248 "localizations": {
1249 "en": { "stringUnit": { "state": "translated", "value": "Start" } }
1250 }
1251 },
1252 "cancel": {
1253 "comment": "Written by a human.",
1254 "localizations": {
1255 "en": { "stringUnit": { "state": "translated", "value": "Cancel" } }
1256 }
1257 },
1258 "retry": {
1259 "comment": "Old auto comment",
1260 "isCommentAutoGenerated": true,
1261 "localizations": {
1262 "en": { "stringUnit": { "state": "translated", "value": "Retry" } }
1263 }
1264 }
1265 }
1266}"#,
1267 )
1268 .expect("write xcstrings");
1269
1270 let mut responses = HashMap::new();
1271 responses.insert(
1272 "start".to_string(),
1273 Some(AnnotationResponse {
1274 comment: "A button label that starts the game.".to_string(),
1275 confidence: "high".to_string(),
1276 }),
1277 );
1278 responses.insert(
1279 "retry".to_string(),
1280 Some(AnnotationResponse {
1281 comment: "A button label shown when the user can try the action again.".to_string(),
1282 confidence: "high".to_string(),
1283 }),
1284 );
1285
1286 let opts = ResolvedAnnotateOptions {
1287 input: input.to_string_lossy().to_string(),
1288 output: input.to_string_lossy().to_string(),
1289 source_roots: vec![source_root.to_string_lossy().to_string()],
1290 source_lang: Some("en".to_string()),
1291 provider: ProviderKind::OpenAI,
1292 model: "test-model".to_string(),
1293 concurrency: 1,
1294 dry_run: false,
1295 check: false,
1296 workspace_root: temp_dir.path().to_path_buf(),
1297 ui_mode: ResolvedUiMode::Plain,
1298 };
1299
1300 run_annotate_with_backend(opts, Arc::new(FakeBackend { responses }))
1301 .expect("annotate command");
1302
1303 let payload = serde_json::from_str::<serde_json::Value>(
1304 &fs::read_to_string(&input).expect("read output"),
1305 )
1306 .expect("parse output");
1307
1308 assert_eq!(
1309 payload["strings"]["start"]["comment"],
1310 serde_json::Value::String("A button label that starts the game.".to_string())
1311 );
1312 assert_eq!(
1313 payload["strings"]["start"]["isCommentAutoGenerated"],
1314 serde_json::Value::Bool(true)
1315 );
1316 assert_eq!(
1317 payload["strings"]["retry"]["comment"],
1318 serde_json::Value::String(
1319 "A button label shown when the user can try the action again.".to_string()
1320 )
1321 );
1322 assert_eq!(
1323 payload["strings"]["cancel"]["comment"],
1324 serde_json::Value::String("Written by a human.".to_string())
1325 );
1326 }
1327
1328 #[test]
1329 fn run_annotate_dry_run_does_not_write_changes() {
1330 let temp_dir = TempDir::new().expect("temp dir");
1331 let input = temp_dir.path().join("Localizable.xcstrings");
1332 let source_root = temp_dir.path().join("Sources");
1333 fs::create_dir_all(&source_root).expect("create root");
1334 fs::write(
1335 &input,
1336 r#"{
1337 "sourceLanguage": "en",
1338 "version": "1.0",
1339 "strings": {
1340 "start": {
1341 "localizations": {
1342 "en": { "stringUnit": { "state": "translated", "value": "Start" } }
1343 }
1344 }
1345 }
1346}"#,
1347 )
1348 .expect("write xcstrings");
1349
1350 let original = fs::read_to_string(&input).expect("read original");
1351 let mut responses = HashMap::new();
1352 responses.insert(
1353 "start".to_string(),
1354 Some(AnnotationResponse {
1355 comment: "A button label that starts the game.".to_string(),
1356 confidence: "high".to_string(),
1357 }),
1358 );
1359
1360 let opts = ResolvedAnnotateOptions {
1361 input: input.to_string_lossy().to_string(),
1362 output: input.to_string_lossy().to_string(),
1363 source_roots: vec![source_root.to_string_lossy().to_string()],
1364 source_lang: Some("en".to_string()),
1365 provider: ProviderKind::OpenAI,
1366 model: "test-model".to_string(),
1367 concurrency: 1,
1368 dry_run: true,
1369 check: false,
1370 workspace_root: temp_dir.path().to_path_buf(),
1371 ui_mode: ResolvedUiMode::Plain,
1372 };
1373
1374 run_annotate_with_backend(opts, Arc::new(FakeBackend { responses }))
1375 .expect("annotate command");
1376
1377 assert_eq!(fs::read_to_string(&input).expect("read output"), original);
1378 }
1379
1380 #[test]
1381 fn run_annotate_check_fails_when_changes_would_be_written() {
1382 let temp_dir = TempDir::new().expect("temp dir");
1383 let input = temp_dir.path().join("Localizable.xcstrings");
1384 let source_root = temp_dir.path().join("Sources");
1385 fs::create_dir_all(&source_root).expect("create root");
1386 fs::write(
1387 &input,
1388 r#"{
1389 "sourceLanguage": "en",
1390 "version": "1.0",
1391 "strings": {
1392 "start": {
1393 "localizations": {
1394 "en": { "stringUnit": { "state": "translated", "value": "Start" } }
1395 }
1396 }
1397 }
1398}"#,
1399 )
1400 .expect("write xcstrings");
1401
1402 let mut responses = HashMap::new();
1403 responses.insert(
1404 "start".to_string(),
1405 Some(AnnotationResponse {
1406 comment: "A button label that starts the game.".to_string(),
1407 confidence: "high".to_string(),
1408 }),
1409 );
1410
1411 let opts = ResolvedAnnotateOptions {
1412 input: input.to_string_lossy().to_string(),
1413 output: input.to_string_lossy().to_string(),
1414 source_roots: vec![source_root.to_string_lossy().to_string()],
1415 source_lang: Some("en".to_string()),
1416 provider: ProviderKind::OpenAI,
1417 model: "test-model".to_string(),
1418 concurrency: 1,
1419 dry_run: false,
1420 check: true,
1421 workspace_root: temp_dir.path().to_path_buf(),
1422 ui_mode: ResolvedUiMode::Plain,
1423 };
1424
1425 let error = run_annotate_with_backend(opts, Arc::new(FakeBackend { responses }))
1426 .expect_err("check mode should fail");
1427 assert!(error.contains("would change"));
1428 }
1429
1430 #[test]
1431 fn annotate_requests_does_not_drop_backend_runtime_inside_async_context() {
1432 let requests = vec![AnnotationRequest {
1433 key: "start".to_string(),
1434 source_lang: "en".to_string(),
1435 source_value: "Start".to_string(),
1436 existing_comment: None,
1437 source_roots: vec!["Sources".to_string()],
1438 }];
1439 let backend: Arc<dyn AnnotationBackend> = Arc::new(RuntimeHoldingBackend {
1440 _runtime: Arc::new(
1441 tokio::runtime::Builder::new_current_thread()
1442 .enable_all()
1443 .build()
1444 .expect("build nested runtime"),
1445 ),
1446 });
1447 let init = DashboardInit {
1448 kind: DashboardKind::Annotate,
1449 title: "test".to_string(),
1450 metadata: Vec::new(),
1451 summary_rows: annotate_summary_rows(1, 0, 0),
1452 items: requests.iter().map(annotate_dashboard_item).collect(),
1453 };
1454 let mut reporter = PlainReporter::new(init);
1455
1456 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1457 annotate_requests(requests, Arc::clone(&backend), 1, &mut reporter)
1458 }));
1459
1460 assert!(result.is_ok(), "annotate_requests should not panic");
1461 let annotations = result.expect("no panic").expect("annotation results");
1462 assert_eq!(annotations.len(), 1);
1463 assert!(annotations["start"].is_some());
1464 }
1465
1466 #[test]
1467 fn resolve_annotate_options_uses_provider_section_defaults() {
1468 let temp_dir = TempDir::new().expect("temp dir");
1469 let project_dir = temp_dir.path().join("project");
1470 let sources_dir = project_dir.join("Sources");
1471 let modules_dir = project_dir.join("Modules");
1472 fs::create_dir_all(&sources_dir).expect("create Sources");
1473 fs::create_dir_all(&modules_dir).expect("create Modules");
1474 let input = project_dir.join("Localizable.xcstrings");
1475 fs::write(
1476 &input,
1477 r#"{
1478 "sourceLanguage": "en",
1479 "version": "1.0",
1480 "strings": {}
1481}"#,
1482 )
1483 .expect("write xcstrings");
1484
1485 let config_path = project_dir.join("langcodec.toml");
1486 fs::write(
1487 &config_path,
1488 r#"[openai]
1489model = "gpt-5.4"
1490
1491[annotate]
1492input = "Localizable.xcstrings"
1493source_roots = ["Sources", "Modules"]
1494output = "Annotated.xcstrings"
1495source_lang = "en"
1496concurrency = 2
1497"#,
1498 )
1499 .expect("write config");
1500
1501 let loaded = load_config(Some(config_path.to_str().expect("config path")))
1502 .expect("load config")
1503 .expect("config present");
1504
1505 let resolved = resolve_annotate_options(
1506 &AnnotateOptions {
1507 input: None,
1508 source_roots: Vec::new(),
1509 output: None,
1510 source_lang: None,
1511 provider: None,
1512 model: None,
1513 concurrency: None,
1514 config: Some(config_path.to_string_lossy().to_string()),
1515 dry_run: false,
1516 check: false,
1517 ui_mode: UiMode::Plain,
1518 },
1519 Some(&loaded),
1520 )
1521 .expect("resolve annotate options");
1522
1523 assert_eq!(resolved.input, input.to_string_lossy().to_string());
1524 assert_eq!(
1525 resolved.output,
1526 project_dir
1527 .join("Annotated.xcstrings")
1528 .to_string_lossy()
1529 .to_string()
1530 );
1531 assert_eq!(
1532 resolved.source_roots,
1533 vec![
1534 sources_dir.to_string_lossy().to_string(),
1535 modules_dir.to_string_lossy().to_string()
1536 ]
1537 );
1538 assert_eq!(resolved.source_lang.as_deref(), Some("en"));
1539 assert_eq!(resolved.provider, ProviderKind::OpenAI);
1540 assert_eq!(resolved.model, "gpt-5.4");
1541 assert_eq!(resolved.concurrency, 2);
1542 }
1543
1544 #[test]
1545 fn resolve_annotate_options_prefers_cli_over_config() {
1546 let temp_dir = TempDir::new().expect("temp dir");
1547 let project_dir = temp_dir.path().join("project");
1548 let config_sources_dir = project_dir.join("Sources");
1549 let cli_sources_dir = project_dir.join("AppSources");
1550 fs::create_dir_all(&config_sources_dir).expect("create config Sources");
1551 fs::create_dir_all(&cli_sources_dir).expect("create cli Sources");
1552 let config_input = project_dir.join("Localizable.xcstrings");
1553 let cli_input = project_dir.join("Runtime.xcstrings");
1554 fs::write(
1555 &config_input,
1556 r#"{
1557 "sourceLanguage": "en",
1558 "version": "1.0",
1559 "strings": {}
1560}"#,
1561 )
1562 .expect("write config xcstrings");
1563 fs::write(
1564 &cli_input,
1565 r#"{
1566 "sourceLanguage": "en",
1567 "version": "1.0",
1568 "strings": {}
1569}"#,
1570 )
1571 .expect("write cli xcstrings");
1572
1573 let config_path = project_dir.join("langcodec.toml");
1574 fs::write(
1575 &config_path,
1576 r#"[openai]
1577model = "gpt-5.4"
1578
1579[annotate]
1580input = "Localizable.xcstrings"
1581source_roots = ["Sources"]
1582source_lang = "en"
1583concurrency = 2
1584"#,
1585 )
1586 .expect("write config");
1587
1588 let loaded = load_config(Some(config_path.to_str().expect("config path")))
1589 .expect("load config")
1590 .expect("config present");
1591
1592 let resolved = resolve_annotate_options(
1593 &AnnotateOptions {
1594 input: Some(cli_input.to_string_lossy().to_string()),
1595 source_roots: vec![cli_sources_dir.to_string_lossy().to_string()],
1596 output: Some(
1597 project_dir
1598 .join("Output.xcstrings")
1599 .to_string_lossy()
1600 .to_string(),
1601 ),
1602 source_lang: Some("fr".to_string()),
1603 provider: Some("anthropic".to_string()),
1604 model: Some("claude-sonnet".to_string()),
1605 concurrency: Some(6),
1606 config: Some(config_path.to_string_lossy().to_string()),
1607 dry_run: true,
1608 check: true,
1609 ui_mode: UiMode::Plain,
1610 },
1611 Some(&loaded),
1612 )
1613 .expect("resolve annotate options");
1614
1615 assert_eq!(resolved.input, cli_input.to_string_lossy().to_string());
1616 assert_eq!(
1617 resolved.source_roots,
1618 vec![cli_sources_dir.to_string_lossy().to_string()]
1619 );
1620 assert_eq!(resolved.source_lang.as_deref(), Some("fr"));
1621 assert_eq!(resolved.provider, ProviderKind::Anthropic);
1622 assert_eq!(resolved.model, "claude-sonnet");
1623 assert_eq!(resolved.concurrency, 6);
1624 assert!(resolved.dry_run);
1625 assert!(resolved.check);
1626 }
1627
1628 #[test]
1629 fn expand_annotate_invocations_supports_multiple_config_inputs() {
1630 let temp_dir = TempDir::new().expect("temp dir");
1631 let project_dir = temp_dir.path().join("project");
1632 let sources_dir = project_dir.join("Sources");
1633 fs::create_dir_all(&sources_dir).expect("create Sources");
1634 let first = project_dir.join("First.xcstrings");
1635 let second = project_dir.join("Second.xcstrings");
1636 fs::write(
1637 &first,
1638 r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
1639 )
1640 .expect("write first");
1641 fs::write(
1642 &second,
1643 r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
1644 )
1645 .expect("write second");
1646
1647 let config_path = project_dir.join("langcodec.toml");
1648 fs::write(
1649 &config_path,
1650 r#"[openai]
1651model = "gpt-5.4"
1652
1653[annotate]
1654inputs = ["First.xcstrings", "Second.xcstrings"]
1655source_roots = ["Sources"]
1656source_lang = "en"
1657concurrency = 2
1658"#,
1659 )
1660 .expect("write config");
1661
1662 let loaded = load_config(Some(config_path.to_str().expect("config path")))
1663 .expect("load config")
1664 .expect("config present");
1665
1666 let runs = expand_annotate_invocations(
1667 &AnnotateOptions {
1668 input: None,
1669 source_roots: Vec::new(),
1670 output: None,
1671 source_lang: None,
1672 provider: None,
1673 model: None,
1674 concurrency: None,
1675 config: Some(config_path.to_string_lossy().to_string()),
1676 dry_run: false,
1677 check: false,
1678 ui_mode: UiMode::Plain,
1679 },
1680 Some(&loaded),
1681 )
1682 .expect("expand annotate invocations");
1683
1684 assert_eq!(runs.len(), 2);
1685 assert_eq!(runs[0].input, first.to_string_lossy().to_string());
1686 assert_eq!(runs[1].input, second.to_string_lossy().to_string());
1687 assert_eq!(
1688 runs[0].source_roots,
1689 vec![sources_dir.to_string_lossy().to_string()]
1690 );
1691 assert_eq!(
1692 runs[1].source_roots,
1693 vec![sources_dir.to_string_lossy().to_string()]
1694 );
1695 }
1696
1697 #[test]
1698 fn expand_annotate_invocations_rejects_input_and_inputs_together() {
1699 let temp_dir = TempDir::new().expect("temp dir");
1700 let config_path = temp_dir.path().join("langcodec.toml");
1701 fs::write(
1702 &config_path,
1703 r#"[annotate]
1704input = "Localizable.xcstrings"
1705inputs = ["One.xcstrings", "Two.xcstrings"]
1706source_roots = ["Sources"]
1707"#,
1708 )
1709 .expect("write config");
1710
1711 let loaded = load_config(Some(config_path.to_str().expect("config path")))
1712 .expect("load config")
1713 .expect("config present");
1714
1715 let err = expand_annotate_invocations(
1716 &AnnotateOptions {
1717 input: None,
1718 source_roots: Vec::new(),
1719 output: None,
1720 source_lang: None,
1721 provider: None,
1722 model: None,
1723 concurrency: None,
1724 config: Some(config_path.to_string_lossy().to_string()),
1725 dry_run: false,
1726 check: false,
1727 ui_mode: UiMode::Plain,
1728 },
1729 Some(&loaded),
1730 )
1731 .expect_err("expected conflicting config to fail");
1732
1733 assert!(err.contains("annotate.input and annotate.inputs"));
1734 }
1735
1736 #[test]
1737 fn expand_annotate_invocations_rejects_shared_output_for_multiple_inputs() {
1738 let temp_dir = TempDir::new().expect("temp dir");
1739 let project_dir = temp_dir.path().join("project");
1740 let sources_dir = project_dir.join("Sources");
1741 fs::create_dir_all(&sources_dir).expect("create Sources");
1742 fs::write(
1743 project_dir.join("One.xcstrings"),
1744 r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
1745 )
1746 .expect("write One");
1747 fs::write(
1748 project_dir.join("Two.xcstrings"),
1749 r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
1750 )
1751 .expect("write Two");
1752
1753 let config_path = project_dir.join("langcodec.toml");
1754 fs::write(
1755 &config_path,
1756 r#"[openai]
1757model = "gpt-5.4"
1758
1759[annotate]
1760inputs = ["One.xcstrings", "Two.xcstrings"]
1761source_roots = ["Sources"]
1762output = "Annotated.xcstrings"
1763"#,
1764 )
1765 .expect("write config");
1766
1767 let loaded = load_config(Some(config_path.to_str().expect("config path")))
1768 .expect("load config")
1769 .expect("config present");
1770
1771 let err = expand_annotate_invocations(
1772 &AnnotateOptions {
1773 input: None,
1774 source_roots: Vec::new(),
1775 output: None,
1776 source_lang: None,
1777 provider: None,
1778 model: None,
1779 concurrency: None,
1780 config: Some(config_path.to_string_lossy().to_string()),
1781 dry_run: false,
1782 check: false,
1783 ui_mode: UiMode::Plain,
1784 },
1785 Some(&loaded),
1786 )
1787 .expect_err("expected multiple input/output conflict");
1788
1789 assert!(err.contains("annotate.inputs cannot be combined"));
1790 }
1791
1792 #[tokio::test]
1793 async fn mentra_backend_requests_files_tool() {
1794 let requests = Arc::new(Mutex::new(Vec::new()));
1795 let provider = RecordingProvider {
1796 requests: Arc::clone(&requests),
1797 };
1798 let runtime = Runtime::builder()
1799 .with_provider_instance(provider)
1800 .build()
1801 .expect("build runtime");
1802 let backend = MentraAnnotatorBackend::from_runtime(
1803 runtime,
1804 ModelInfo::new("test-model", BuiltinProvider::OpenAI),
1805 PathBuf::from("/tmp/project"),
1806 );
1807
1808 let response = backend
1809 .annotate(
1810 AnnotationRequest {
1811 key: "start".to_string(),
1812 source_lang: "en".to_string(),
1813 source_value: "Start".to_string(),
1814 existing_comment: None,
1815 source_roots: vec!["Sources".to_string()],
1816 },
1817 None,
1818 )
1819 .await
1820 .expect("annotate")
1821 .expect("response");
1822
1823 assert_eq!(response.comment, "A button label that starts the game.");
1824 let recorded = requests.lock().expect("requests lock");
1825 assert_eq!(recorded.len(), 1);
1826 let tool_names = recorded[0]
1827 .tools
1828 .iter()
1829 .map(|tool| tool.name.as_str())
1830 .collect::<Vec<_>>();
1831 assert!(tool_names.contains(&"files"));
1832 assert!(tool_names.contains(&"shell"));
1833 }
1834
1835 #[tokio::test]
1836 async fn old_tool_enabled_annotate_flow_recovers_from_malformed_tool_json_on_mentra_030() {
1837 let requests = Arc::new(Mutex::new(Vec::new()));
1838 let scripts = VecDeque::from([
1839 vec![
1840 ProviderEvent::MessageStarted {
1841 id: "msg-1".to_string(),
1842 model: "test-model".to_string(),
1843 role: Role::Assistant,
1844 },
1845 ProviderEvent::ContentBlockStarted {
1846 index: 0,
1847 kind: ContentBlockStart::ToolUse {
1848 id: "tool-1".to_string(),
1849 name: "files".to_string(),
1850 },
1851 },
1852 ProviderEvent::ContentBlockDelta {
1853 index: 0,
1854 delta: ContentBlockDelta::ToolUseInputJson(
1855 r#"{"path":"Sources/GameView.swift"#.to_string(),
1856 ),
1857 },
1858 ProviderEvent::ContentBlockStopped { index: 0 },
1859 ProviderEvent::MessageStopped,
1860 ],
1861 Response {
1862 id: "resp-2".to_string(),
1863 model: "test-model".to_string(),
1864 role: Role::Assistant,
1865 content: vec![ContentBlock::text(
1866 r#"{"comment":"A button label that starts the game.","confidence":"high"}"#,
1867 )],
1868 stop_reason: Some("end_turn".to_string()),
1869 usage: None,
1870 }
1871 .into_provider_events(),
1872 ]);
1873 let provider = ScriptedStreamingProvider {
1874 requests: Arc::clone(&requests),
1875 scripts: Arc::new(Mutex::new(scripts)),
1876 };
1877 let runtime = Runtime::builder()
1878 .with_provider_instance(provider)
1879 .build()
1880 .expect("build runtime");
1881 let mut agent = runtime
1882 .spawn_with_config(
1883 "annotate",
1884 ModelInfo::new("test-model", BuiltinProvider::OpenAI),
1885 build_agent_config(Path::new("/tmp/project")),
1886 )
1887 .expect("spawn agent");
1888 let request = AnnotationRequest {
1889 key: "start".to_string(),
1890 source_lang: "en".to_string(),
1891 source_value: "Start".to_string(),
1892 existing_comment: None,
1893 source_roots: vec!["Sources".to_string()],
1894 };
1895
1896 let response = agent
1897 .run(
1898 vec![ContentBlock::text(build_annotation_prompt(&request))],
1899 RunOptions {
1900 tool_budget: Some(DEFAULT_TOOL_BUDGET),
1901 ..RunOptions::default()
1902 },
1903 )
1904 .await
1905 .expect("run annotate");
1906 let parsed = parse_annotation_response(&response.text()).expect("parse annotation");
1907
1908 assert_eq!(parsed.comment, "A button label that starts the game.");
1909 let recorded = requests.lock().expect("requests lock");
1910 assert_eq!(recorded.len(), 2);
1911 assert!(
1912 recorded[1]
1913 .messages
1914 .iter()
1915 .flat_map(|message| message.content.iter())
1916 .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.")))
1917 );
1918 }
1919}