Skip to main content

langcodec_cli/
annotate.rs

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}