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}