Skip to main content

langcodec_cli/
config.rs

1use serde::Deserialize;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, Default, Deserialize)]
5pub struct CliConfig {
6    #[serde(default)]
7    pub openai: ProviderConfig,
8    #[serde(default)]
9    pub anthropic: ProviderConfig,
10    #[serde(default)]
11    pub gemini: ProviderConfig,
12    #[serde(default)]
13    pub tolgee: TolgeeConfig,
14    #[serde(default)]
15    pub translate: TranslateConfig,
16    #[serde(default)]
17    pub annotate: AnnotateConfig,
18}
19
20#[derive(Debug, Clone, Default, Deserialize)]
21pub struct ProviderConfig {
22    pub model: Option<String>,
23}
24
25#[derive(Debug, Clone, Default, Deserialize)]
26pub struct TranslateConfig {
27    pub source: Option<String>,
28    pub sources: Option<Vec<String>>,
29    pub target: Option<String>,
30    pub provider: Option<String>,
31    pub model: Option<String>,
32    pub source_lang: Option<String>,
33    pub use_tolgee: Option<bool>,
34    #[serde(default, deserialize_with = "deserialize_optional_string_or_vec")]
35    pub target_lang: Option<Vec<String>>,
36    pub concurrency: Option<usize>,
37    pub status: Option<Vec<String>>,
38    pub output_status: Option<String>,
39    #[serde(default)]
40    pub input: TranslateInputConfig,
41    pub output: Option<TranslateOutputScope>,
42}
43
44#[derive(Debug, Clone, Default, Deserialize)]
45pub struct TolgeeConfig {
46    pub config: Option<String>,
47    pub project_id: Option<u64>,
48    pub api_url: Option<String>,
49    pub api_key: Option<String>,
50    pub format: Option<String>,
51    pub schema: Option<String>,
52    #[serde(default, deserialize_with = "deserialize_optional_string_or_vec")]
53    pub namespaces: Option<Vec<String>>,
54    #[serde(default)]
55    pub push: TolgeePushConfig,
56    #[serde(default)]
57    pub pull: TolgeePullConfig,
58}
59
60#[derive(Debug, Clone, Default, Deserialize)]
61pub struct TolgeePushConfig {
62    #[serde(
63        default,
64        alias = "language",
65        deserialize_with = "deserialize_optional_string_or_vec"
66    )]
67    pub languages: Option<Vec<String>>,
68    pub force_mode: Option<String>,
69    #[serde(default)]
70    pub files: Vec<TolgeePushFileConfig>,
71}
72
73#[derive(Debug, Clone, Default, Deserialize)]
74pub struct TolgeePushFileConfig {
75    pub path: String,
76    pub namespace: String,
77}
78
79#[derive(Debug, Clone, Default, Deserialize)]
80pub struct TolgeePullConfig {
81    pub path: Option<String>,
82    pub file_structure_template: Option<String>,
83}
84
85#[derive(Debug, Clone, Default, Deserialize)]
86pub struct TranslateInputConfig {
87    pub source: Option<String>,
88    pub sources: Option<Vec<String>>,
89    pub lang: Option<String>,
90    pub status: Option<Vec<String>>,
91}
92
93#[derive(Debug, Clone, Deserialize)]
94#[serde(untagged)]
95pub enum TranslateOutputScope {
96    Path(String),
97    Config(TranslateOutputConfig),
98}
99
100#[derive(Debug, Clone, Default, Deserialize)]
101pub struct TranslateOutputConfig {
102    pub target: Option<String>,
103    pub path: Option<String>,
104    #[serde(default, deserialize_with = "deserialize_optional_string_or_vec")]
105    pub lang: Option<Vec<String>>,
106    pub status: Option<String>,
107}
108
109#[derive(Debug, Clone, Default, Deserialize)]
110pub struct AnnotateConfig {
111    pub input: Option<String>,
112    pub inputs: Option<Vec<String>>,
113    pub source_roots: Option<Vec<String>>,
114    pub output: Option<String>,
115    pub source_lang: Option<String>,
116    pub concurrency: Option<usize>,
117}
118
119#[derive(Debug, Clone)]
120pub struct LoadedConfig {
121    pub path: PathBuf,
122    pub data: CliConfig,
123}
124
125impl LoadedConfig {
126    pub fn config_dir(&self) -> Option<&Path> {
127        self.path.parent()
128    }
129}
130
131impl CliConfig {
132    pub fn provider_model(&self, provider: &str) -> Option<&str> {
133        match provider.trim().to_ascii_lowercase().as_str() {
134            "openai" => self.openai.model.as_deref(),
135            "anthropic" => self.anthropic.model.as_deref(),
136            "gemini" => self.gemini.model.as_deref(),
137            _ => None,
138        }
139    }
140
141    pub fn configured_provider_names(&self) -> Vec<&'static str> {
142        let mut names = Vec::new();
143        if self.openai.model.is_some() {
144            names.push("openai");
145        }
146        if self.anthropic.model.is_some() {
147            names.push("anthropic");
148        }
149        if self.gemini.model.is_some() {
150            names.push("gemini");
151        }
152        names
153    }
154}
155
156impl TolgeeConfig {
157    pub fn has_inline_runtime_config(&self) -> bool {
158        self.project_id.is_some()
159            || self.api_url.is_some()
160            || self.api_key.is_some()
161            || self.format.is_some()
162            || self.schema.is_some()
163            || self.push.languages.is_some()
164            || self.push.force_mode.is_some()
165            || !self.push.files.is_empty()
166            || self.pull.path.is_some()
167            || self.pull.file_structure_template.is_some()
168    }
169}
170
171impl TranslateConfig {
172    pub fn resolved_source(&self) -> Option<&str> {
173        self.input.source.as_deref().or(self.source.as_deref())
174    }
175
176    pub fn resolved_sources(&self) -> Option<&Vec<String>> {
177        self.input.sources.as_ref().or(self.sources.as_ref())
178    }
179
180    pub fn resolved_source_lang(&self) -> Option<&str> {
181        self.input.lang.as_deref().or(self.source_lang.as_deref())
182    }
183
184    pub fn resolved_filter_status(&self) -> Option<&Vec<String>> {
185        self.input.status.as_ref().or(self.status.as_ref())
186    }
187
188    pub fn resolved_target(&self) -> Option<&str> {
189        match self.output.as_ref() {
190            Some(TranslateOutputScope::Config(config)) => {
191                config.target.as_deref().or(self.target.as_deref())
192            }
193            _ => self.target.as_deref(),
194        }
195    }
196
197    pub fn resolved_output_path(&self) -> Option<&str> {
198        match self.output.as_ref() {
199            Some(TranslateOutputScope::Path(path)) => Some(path.as_str()),
200            Some(TranslateOutputScope::Config(config)) => config.path.as_deref(),
201            None => None,
202        }
203    }
204
205    pub fn resolved_target_langs(&self) -> Option<&Vec<String>> {
206        match self.output.as_ref() {
207            Some(TranslateOutputScope::Config(config)) => {
208                config.lang.as_ref().or(self.target_lang.as_ref())
209            }
210            _ => self.target_lang.as_ref(),
211        }
212    }
213
214    pub fn resolved_output_status(&self) -> Option<&str> {
215        match self.output.as_ref() {
216            Some(TranslateOutputScope::Config(config)) => {
217                config.status.as_deref().or(self.output_status.as_deref())
218            }
219            _ => self.output_status.as_deref(),
220        }
221    }
222}
223
224pub fn load_config(explicit_path: Option<&str>) -> Result<Option<LoadedConfig>, String> {
225    let path = match explicit_path {
226        Some(path) => {
227            let resolved = PathBuf::from(path);
228            if !resolved.exists() {
229                return Err(format!(
230                    "Config file does not exist: {}",
231                    resolved.display()
232                ));
233            }
234            resolved
235        }
236        None => match discover_config_path()? {
237            Some(path) => path,
238            None => return Ok(None),
239        },
240    };
241
242    let text = std::fs::read_to_string(&path)
243        .map_err(|e| format!("Failed to read config '{}': {}", path.display(), e))?;
244    let data: CliConfig = toml::from_str(&text)
245        .map_err(|e| format!("Failed to parse config '{}': {}", path.display(), e))?;
246    Ok(Some(LoadedConfig { path, data }))
247}
248
249fn discover_config_path() -> Result<Option<PathBuf>, String> {
250    let mut current = std::env::current_dir()
251        .map_err(|e| format!("Failed to determine current directory: {}", e))?;
252
253    loop {
254        let candidate = current.join("langcodec.toml");
255        if candidate.is_file() {
256            return Ok(Some(candidate));
257        }
258
259        if !current.pop() {
260            return Ok(None);
261        }
262    }
263}
264
265pub fn resolve_config_relative_path(config_dir: Option<&Path>, path: &str) -> String {
266    let candidate = Path::new(path);
267    if candidate.is_absolute() {
268        return candidate.to_string_lossy().to_string();
269    }
270
271    match config_dir {
272        Some(dir) => dir.join(candidate).to_string_lossy().to_string(),
273        None => candidate.to_string_lossy().to_string(),
274    }
275}
276
277fn deserialize_optional_string_or_vec<'de, D>(
278    deserializer: D,
279) -> Result<Option<Vec<String>>, D::Error>
280where
281    D: serde::Deserializer<'de>,
282{
283    #[derive(Deserialize)]
284    #[serde(untagged)]
285    enum StringOrVec {
286        String(String),
287        Vec(Vec<String>),
288    }
289
290    let value = Option::<StringOrVec>::deserialize(deserializer)?;
291    Ok(value.map(|value| match value {
292        StringOrVec::String(value) => vec![value],
293        StringOrVec::Vec(values) => values,
294    }))
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use std::fs;
301
302    #[test]
303    fn cli_config_lists_provider_sections() {
304        let config: CliConfig = toml::from_str(
305            r#"
306[openai]
307model = "gpt-5.4"
308
309[anthropic]
310model = "claude-sonnet"
311"#,
312        )
313        .expect("parse config");
314
315        assert_eq!(
316            config.configured_provider_names(),
317            vec!["openai", "anthropic"]
318        );
319    }
320
321    #[test]
322    fn cli_config_reads_provider_specific_models() {
323        let config: CliConfig = toml::from_str(
324            r#"
325[openai]
326model = "gpt-5.4"
327
328[anthropic]
329model = "claude-sonnet"
330"#,
331        )
332        .expect("parse config");
333
334        assert_eq!(config.provider_model("openai"), Some("gpt-5.4"));
335        assert_eq!(config.provider_model("anthropic"), Some("claude-sonnet"));
336        assert_eq!(config.provider_model("gemini"), None);
337    }
338
339    #[test]
340    fn resolve_config_relative_path_uses_config_dir() {
341        let resolved = resolve_config_relative_path(
342            Some(Path::new("/tmp/project")),
343            "locales/Localizable.xcstrings",
344        );
345        assert_eq!(resolved, "/tmp/project/locales/Localizable.xcstrings");
346    }
347
348    #[test]
349    fn load_config_parses_annotate_section() {
350        let temp_dir = tempfile::TempDir::new().expect("temp dir");
351        let config_path = temp_dir.path().join("langcodec.toml");
352        fs::write(
353            &config_path,
354            r#"
355[openai]
356model = "gpt-5.4"
357
358[annotate]
359input = "locales/Localizable.xcstrings"
360source_roots = ["Sources", "Modules"]
361concurrency = 2
362"#,
363        )
364        .expect("write config");
365
366        let loaded = load_config(Some(config_path.to_str().expect("config path")))
367            .expect("load config")
368            .expect("config present");
369
370        assert_eq!(
371            loaded.data.annotate.input.as_deref(),
372            Some("locales/Localizable.xcstrings")
373        );
374        assert_eq!(
375            loaded.data.annotate.source_roots,
376            Some(vec!["Sources".to_string(), "Modules".to_string()])
377        );
378        assert_eq!(loaded.data.annotate.concurrency, Some(2));
379    }
380
381    #[test]
382    fn load_config_parses_annotate_inputs_section() {
383        let temp_dir = tempfile::TempDir::new().expect("temp dir");
384        let config_path = temp_dir.path().join("langcodec.toml");
385        fs::write(
386            &config_path,
387            r#"
388[openai]
389model = "gpt-5.4"
390
391[annotate]
392inputs = ["locales/A.xcstrings", "locales/B.xcstrings"]
393source_roots = ["Sources"]
394concurrency = 2
395"#,
396        )
397        .expect("write config");
398
399        let loaded = load_config(Some(config_path.to_str().expect("config path")))
400            .expect("load config")
401            .expect("config present");
402
403        assert_eq!(
404            loaded.data.annotate.inputs,
405            Some(vec![
406                "locales/A.xcstrings".to_string(),
407                "locales/B.xcstrings".to_string()
408            ])
409        );
410    }
411
412    #[test]
413    fn load_config_parses_translate_target_lang_array() {
414        let config: CliConfig = toml::from_str(
415            r#"
416[translate]
417target_lang = ["fr", "de"]
418"#,
419        )
420        .expect("parse config");
421
422        assert_eq!(
423            config.translate.target_lang,
424            Some(vec!["fr".to_string(), "de".to_string()])
425        );
426    }
427
428    #[test]
429    fn load_config_preserves_legacy_translate_target_lang_string() {
430        let config: CliConfig = toml::from_str(
431            r#"
432[translate]
433target_lang = "fr,de"
434"#,
435        )
436        .expect("parse config");
437
438        assert_eq!(
439            config.translate.target_lang,
440            Some(vec!["fr,de".to_string()])
441        );
442    }
443
444    #[test]
445    fn load_config_parses_nested_translate_input_output_sections() {
446        let config: CliConfig = toml::from_str(
447            r#"
448[translate.input]
449source = "locales/Localizable.xcstrings"
450lang = "en"
451status = ["new", "stale"]
452
453[translate.output]
454target = "locales/Translated.xcstrings"
455path = "build/Translated.xcstrings"
456lang = ["fr", "de"]
457status = "translated"
458"#,
459        )
460        .expect("parse config");
461
462        assert_eq!(
463            config.translate.resolved_source(),
464            Some("locales/Localizable.xcstrings")
465        );
466        assert_eq!(config.translate.resolved_source_lang(), Some("en"));
467        assert_eq!(
468            config.translate.resolved_filter_status(),
469            Some(&vec!["new".to_string(), "stale".to_string()])
470        );
471        assert_eq!(
472            config.translate.resolved_target(),
473            Some("locales/Translated.xcstrings")
474        );
475        assert_eq!(
476            config.translate.resolved_output_path(),
477            Some("build/Translated.xcstrings")
478        );
479        assert_eq!(
480            config.translate.resolved_target_langs(),
481            Some(&vec!["fr".to_string(), "de".to_string()])
482        );
483        assert_eq!(
484            config.translate.resolved_output_status(),
485            Some("translated")
486        );
487    }
488
489    #[test]
490    fn load_config_parses_tolgee_defaults() {
491        let config: CliConfig = toml::from_str(
492            r#"
493[tolgee]
494config = ".tolgeerc.json"
495project_id = 36
496api_url = "https://tolgee.example/api"
497api_key = "tgpak_example"
498format = "APPLE_XCSTRINGS"
499schema = "https://docs.tolgee.io/cli-schema.json"
500namespaces = ["WebGame"]
501
502[tolgee.push]
503languages = ["en"]
504force_mode = "KEEP"
505
506[[tolgee.push.files]]
507path = "Modules/WebGame/Localizable.xcstrings"
508namespace = "WebGame"
509
510[tolgee.pull]
511path = "./tolgee-temp"
512file_structure_template = "/{namespace}/Localizable.{extension}"
513
514[translate]
515use_tolgee = true
516"#,
517        )
518        .expect("parse config");
519
520        assert_eq!(config.tolgee.config.as_deref(), Some(".tolgeerc.json"));
521        assert_eq!(config.tolgee.project_id, Some(36));
522        assert_eq!(
523            config.tolgee.api_url.as_deref(),
524            Some("https://tolgee.example/api")
525        );
526        assert_eq!(config.tolgee.api_key.as_deref(), Some("tgpak_example"));
527        assert_eq!(config.tolgee.format.as_deref(), Some("APPLE_XCSTRINGS"));
528        assert_eq!(
529            config.tolgee.schema.as_deref(),
530            Some("https://docs.tolgee.io/cli-schema.json")
531        );
532        assert_eq!(config.tolgee.namespaces, Some(vec!["WebGame".to_string()]));
533        assert_eq!(config.tolgee.push.languages, Some(vec!["en".to_string()]));
534        assert_eq!(config.tolgee.push.force_mode.as_deref(), Some("KEEP"));
535        assert_eq!(config.tolgee.push.files.len(), 1);
536        assert_eq!(
537            config.tolgee.push.files[0].path,
538            "Modules/WebGame/Localizable.xcstrings"
539        );
540        assert_eq!(config.tolgee.pull.path.as_deref(), Some("./tolgee-temp"));
541        assert_eq!(
542            config.tolgee.pull.file_structure_template.as_deref(),
543            Some("/{namespace}/Localizable.{extension}")
544        );
545        assert!(config.tolgee.has_inline_runtime_config());
546        assert_eq!(config.translate.use_tolgee, Some(true));
547    }
548
549    #[test]
550    fn load_config_parses_legacy_tolgee_language_alias() {
551        let config: CliConfig = toml::from_str(
552            r#"
553[tolgee]
554project_id = 36
555
556[tolgee.push]
557language = ["en"]
558
559[[tolgee.push.files]]
560path = "Modules/WebGame/Localizable.xcstrings"
561namespace = "WebGame"
562"#,
563        )
564        .expect("parse config");
565
566        assert_eq!(config.tolgee.push.languages, Some(vec!["en".to_string()]));
567    }
568}