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