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