Skip to main content

langcodec_cli/
config.rs

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