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}