From a0cefd3ae30fd75e1153f99d6bfb2eee526e5276 Mon Sep 17 00:00:00 2001 From: Wendell Date: Sat, 16 May 2026 13:35:57 +0800 Subject: [PATCH 1/3] add user-scoped tolgee credentials --- langcodec-cli/src/config.rs | 102 ++++++++++++++ langcodec-cli/src/tolgee.rs | 90 ++++++++++++- langcodec-cli/tests/tolgee_cli_tests.rs | 172 ++++++++++++++++++++++++ 3 files changed, 363 insertions(+), 1 deletion(-) diff --git a/langcodec-cli/src/config.rs b/langcodec-cli/src/config.rs index 937d67a..5cbccaa 100644 --- a/langcodec-cli/src/config.rs +++ b/langcodec-cli/src/config.rs @@ -1,4 +1,5 @@ use serde::Deserialize; +use std::collections::HashMap; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Default, Deserialize)] @@ -82,6 +83,24 @@ pub struct TolgeePullConfig { pub file_structure_template: Option, } +#[derive(Debug, Clone, Default, Deserialize)] +pub struct UserConfig { + #[serde(default)] + pub tolgee: UserTolgeeConfig, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct UserTolgeeConfig { + pub api_key: Option, + #[serde(default)] + pub projects: HashMap, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct UserTolgeeProjectConfig { + pub api_key: Option, +} + #[derive(Debug, Clone, Default, Deserialize)] pub struct TranslateInputConfig { pub source: Option, @@ -122,6 +141,11 @@ pub struct LoadedConfig { pub data: CliConfig, } +#[derive(Debug, Clone)] +pub struct LoadedUserConfig { + pub data: UserConfig, +} + impl LoadedConfig { pub fn config_dir(&self) -> Option<&Path> { self.path.parent() @@ -168,6 +192,15 @@ impl TolgeeConfig { } } +impl UserTolgeeConfig { + pub fn api_key_for_project(&self, project_id: Option) -> Option<&str> { + project_id + .and_then(|id| self.projects.get(&id.to_string())) + .and_then(|project| project.api_key.as_deref()) + .or(self.api_key.as_deref()) + } +} + impl TranslateConfig { pub fn resolved_source(&self) -> Option<&str> { self.input.source.as_deref().or(self.source.as_deref()) @@ -246,6 +279,18 @@ pub fn load_config(explicit_path: Option<&str>) -> Result, Ok(Some(LoadedConfig { path, data })) } +pub fn load_user_config() -> Result, String> { + let Some(path) = discover_user_config_path() else { + return Ok(None); + }; + + let text = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read user config '{}': {}", path.display(), e))?; + let data: UserConfig = toml::from_str(&text) + .map_err(|e| format!("Failed to parse user config '{}': {}", path.display(), e))?; + Ok(Some(LoadedUserConfig { data })) +} + fn discover_config_path() -> Result, String> { let mut current = std::env::current_dir() .map_err(|e| format!("Failed to determine current directory: {}", e))?; @@ -262,6 +307,20 @@ fn discover_config_path() -> Result, String> { } } +fn discover_user_config_path() -> Option { + let config_home = std::env::var_os("XDG_CONFIG_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(|home| PathBuf::from(home).join(".config")) + })?; + + let candidate = config_home.join("langcodec").join("config.toml"); + candidate.is_file().then_some(candidate) +} + pub fn resolve_config_relative_path(config_dir: Option<&Path>, path: &str) -> String { let candidate = Path::new(path); if candidate.is_absolute() { @@ -565,4 +624,47 @@ namespace = "WebGame" assert_eq!(config.tolgee.push.languages, Some(vec!["en".to_string()])); } + + #[test] + fn user_config_parses_tolgee_global_api_key() { + let config: UserConfig = toml::from_str( + r#" +[tolgee] +api_key = "tgpak_user_default_key" +"#, + ) + .expect("parse user config"); + + assert_eq!( + config.tolgee.api_key.as_deref(), + Some("tgpak_user_default_key") + ); + assert_eq!( + config.tolgee.api_key_for_project(Some(36)), + Some("tgpak_user_default_key") + ); + } + + #[test] + fn user_config_parses_tolgee_project_api_key() { + let config: UserConfig = toml::from_str( + r#" +[tolgee] +api_key = "tgpak_user_default_key" + +[tolgee.projects.36] +api_key = "tgpak_project_specific_key" +"#, + ) + .expect("parse user config"); + + assert_eq!( + config.tolgee.api_key_for_project(Some(36)), + Some("tgpak_project_specific_key") + ); + assert_eq!( + config.tolgee.api_key_for_project(Some(37)), + Some("tgpak_user_default_key") + ); + } } diff --git a/langcodec-cli/src/tolgee.rs b/langcodec-cli/src/tolgee.rs index ab38be8..3d3eedd 100644 --- a/langcodec-cli/src/tolgee.rs +++ b/langcodec-cli/src/tolgee.rs @@ -11,7 +11,10 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use crate::config::{LoadedConfig, TolgeeConfig, load_config, resolve_config_relative_path}; +use crate::config::{ + LoadedConfig, TolgeeConfig, UserConfig, load_config, load_user_config, + resolve_config_relative_path, +}; const DEFAULT_TOLGEE_CONFIG: &str = ".tolgeerc.json"; const TOLGEE_FORMAT_APPLE_XCSTRINGS: &str = "APPLE_XCSTRINGS"; @@ -69,6 +72,7 @@ struct TolgeeProject { config_path: PathBuf, project_root: PathBuf, raw: Value, + user_api_key: Option, pull_template: String, mappings: Vec, } @@ -432,16 +436,60 @@ fn build_tolgee_project_from_raw( .and_then(Value::as_str) .unwrap_or(DEFAULT_PULL_TEMPLATE) .to_string(); + let user_api_key = resolve_tolgee_user_api_key(&raw)?; Ok(TolgeeProject { config_path, project_root, raw, + user_api_key, pull_template, mappings, }) } +fn resolve_tolgee_user_api_key(raw: &Value) -> Result, String> { + if tolgee_has_project_api_key(raw) { + return Ok(None); + } + + let Some(user_config) = load_user_config()? else { + return Ok(None); + }; + Ok(resolve_tolgee_user_api_key_from_config( + raw, + Some(&user_config.data), + )) +} + +fn resolve_tolgee_user_api_key_from_config( + raw: &Value, + user_config: Option<&UserConfig>, +) -> Option { + if tolgee_has_project_api_key(raw) { + return None; + } + + user_config.and_then(|config| { + config + .tolgee + .api_key_for_project(tolgee_project_id(raw)) + .map(str::to_string) + }) +} + +fn tolgee_has_project_api_key(raw: &Value) -> bool { + raw.get("apiKey").is_some() +} + +fn tolgee_project_id(raw: &Value) -> Option { + raw.get("projectId").and_then(|value| { + value + .as_u64() + .or_else(|| value.as_str().and_then(|value| value.parse().ok())) + }) +} + fn normalize_tolgee_raw(raw: &mut Value) { let Some(push) = raw.get_mut("push").and_then(Value::as_object_mut) else { return; @@ -615,6 +663,10 @@ fn invoke_tolgee( } }; + if let Some(api_key) = &project.user_api_key { + command.env("TOLGEE_API_KEY", api_key); + } + let output = command .arg("--config") .arg(&config_path) @@ -982,6 +1034,7 @@ mod tests { config_path: PathBuf::from("/tmp/.tolgeerc.json"), project_root: PathBuf::from("/tmp"), raw: json!({}), + user_api_key: None, pull_template: "/{namespace}/{languageTag}.{extension}".to_string(), mappings: Vec::new(), }; @@ -1072,4 +1125,39 @@ file_structure_template = "/{namespace}/Localizable.{extension}" assert_eq!(project.raw["push"]["languages"], json!(["en"])); assert!(project.raw["push"].get("language").is_none()); } + + #[test] + fn resolves_tolgee_credentials_by_precedence() { + let user_config: UserConfig = toml::from_str( + r#" +[tolgee] +api_key = "tgpak_user_global" + +[tolgee.projects.36] +api_key = "tgpak_user_project" +"#, + ) + .unwrap(); + + let project_key = resolve_tolgee_user_api_key_from_config( + &json!({ + "projectId": 36, + "apiKey": "tgpak_project" + }), + Some(&user_config), + ); + assert_eq!(project_key, None); + + let user_project_key = resolve_tolgee_user_api_key_from_config( + &json!({ "projectId": 36 }), + Some(&user_config), + ); + assert_eq!(user_project_key.as_deref(), Some("tgpak_user_project")); + + let user_global_key = resolve_tolgee_user_api_key_from_config( + &json!({ "projectId": 37 }), + Some(&user_config), + ); + assert_eq!(user_global_key.as_deref(), Some("tgpak_user_global")); + } } diff --git a/langcodec-cli/tests/tolgee_cli_tests.rs b/langcodec-cli/tests/tolgee_cli_tests.rs index 7681936..59f3278 100644 --- a/langcodec-cli/tests/tolgee_cli_tests.rs +++ b/langcodec-cli/tests/tolgee_cli_tests.rs @@ -52,6 +52,7 @@ if [ -z "$config" ] || [ -z "$subcommand" ]; then fi echo "$subcommand|$config" >> "{log_path}" +echo "env|${{TOLGEE_API_KEY-}}" >> "{log_path}" cp "$config" "{capture_path}" if [ "$subcommand" = "push" ]; then @@ -148,6 +149,41 @@ file_structure_template = "/{{namespace}}/Localizable.{{extension}}" config_path } +fn write_langcodec_tolgee_config_without_api_key(project_root: &Path, file_path: &str) -> PathBuf { + let config_path = project_root.join("langcodec.toml"); + fs::write( + &config_path, + format!( + r#"[tolgee] +project_id = 36 +api_url = "https://tolgee.example/api" +namespaces = ["Core"] + +[tolgee.push] +languages = ["en"] +force_mode = "KEEP" + +[[tolgee.push.files]] +path = "{file_path}" +namespace = "Core" + +[tolgee.pull] +path = "./tolgee-temp" +file_structure_template = "/{{namespace}}/Localizable.{{extension}}" +"# + ), + ) + .unwrap(); + config_path +} + +fn write_user_tolgee_config(config_home: &Path, contents: &str) -> PathBuf { + let config_path = config_home.join("langcodec").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, contents).unwrap(); + config_path +} + fn write_local_catalog(path: &Path) { fs::write( path, @@ -373,6 +409,142 @@ fn tolgee_pull_uses_langcodec_toml_without_tolgeerc_json() { let captured = fs::read_to_string(&capture).unwrap(); assert!(captured.contains("\"projectId\"")); assert!(captured.contains("\"apiUrl\"")); + assert!(captured.contains("\"tgpak_example\"")); assert!(captured.contains("\"push\"")); assert!(captured.contains("\"pull\"")); } + +#[test] +fn tolgee_user_project_api_key_is_passed_via_env_not_overlay() { + let temp_dir = TempDir::new().unwrap(); + let project_root = temp_dir.path().join("project"); + fs::create_dir_all(&project_root).unwrap(); + let config_home = temp_dir.path().join("xdg"); + let local_catalog = project_root.join("Localizable.xcstrings"); + let payload = project_root.join("pull_payload.xcstrings"); + let capture = project_root.join("captured_config.json"); + let log = project_root.join("tolgee.log"); + + write_local_catalog(&local_catalog); + write_pulled_payload(&payload, false); + write_langcodec_tolgee_config_without_api_key(&project_root, "Localizable.xcstrings"); + write_user_tolgee_config( + &config_home, + r#"[tolgee] +api_key = "tgpak_user_global" + +[tolgee.projects.36] +api_key = "tgpak_user_project" +"#, + ); + write_fake_tolgee(&project_root, &payload, &capture, &log); + + let output = langcodec_cmd() + .current_dir(&project_root) + .env("XDG_CONFIG_HOME", &config_home) + .env("TOLGEE_API_KEY", "tgpak_env_should_not_win") + .args(["tolgee", "push", "--namespace", "Core"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let log_contents = fs::read_to_string(&log).unwrap(); + assert!(log_contents.contains("env|tgpak_user_project")); + + let captured = fs::read_to_string(&capture).unwrap(); + assert!(captured.contains("\"namespaces\"")); + assert!(!captured.contains("tgpak_user_project")); + assert!(!captured.contains("tgpak_user_global")); + assert!(!captured.contains("\"apiKey\"")); +} + +#[test] +fn tolgee_project_api_key_wins_over_user_config() { + let temp_dir = TempDir::new().unwrap(); + let project_root = temp_dir.path().join("project"); + fs::create_dir_all(&project_root).unwrap(); + let config_home = temp_dir.path().join("xdg"); + let local_catalog = project_root.join("Localizable.xcstrings"); + let payload = project_root.join("pull_payload.xcstrings"); + let capture = project_root.join("captured_config.json"); + let log = project_root.join("tolgee.log"); + + write_local_catalog(&local_catalog); + write_pulled_payload(&payload, false); + write_langcodec_tolgee_config(&project_root, "Localizable.xcstrings"); + write_user_tolgee_config( + &config_home, + r#"[tolgee] +api_key = "tgpak_user_global" + +[tolgee.projects.36] +api_key = "tgpak_user_project" +"#, + ); + write_fake_tolgee(&project_root, &payload, &capture, &log); + + let output = langcodec_cmd() + .current_dir(&project_root) + .env("XDG_CONFIG_HOME", &config_home) + .env_remove("TOLGEE_API_KEY") + .args(["tolgee", "push", "--namespace", "Core"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let log_contents = fs::read_to_string(&log).unwrap(); + assert!(log_contents.contains("env|\n")); + + let captured = fs::read_to_string(&capture).unwrap(); + assert!(captured.contains("tgpak_example")); + assert!(!captured.contains("tgpak_user_project")); + assert!(!captured.contains("tgpak_user_global")); +} + +#[test] +fn tolgee_env_api_key_remains_fallback_without_user_config() { + let temp_dir = TempDir::new().unwrap(); + let project_root = temp_dir.path().join("project"); + fs::create_dir_all(&project_root).unwrap(); + let config_home = temp_dir.path().join("empty-xdg"); + fs::create_dir_all(&config_home).unwrap(); + let local_catalog = project_root.join("Localizable.xcstrings"); + let payload = project_root.join("pull_payload.xcstrings"); + let capture = project_root.join("captured_config.json"); + let log = project_root.join("tolgee.log"); + + write_local_catalog(&local_catalog); + write_pulled_payload(&payload, false); + let config = write_tolgee_config(&project_root, "Localizable.xcstrings"); + write_fake_tolgee(&project_root, &payload, &capture, &log); + + let output = langcodec_cmd() + .current_dir(&project_root) + .env("XDG_CONFIG_HOME", &config_home) + .env("TOLGEE_API_KEY", "tgpak_env_fallback") + .args(["tolgee", "push", "--config", config.to_str().unwrap()]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let log_contents = fs::read_to_string(&log).unwrap(); + assert!(log_contents.contains("env|tgpak_env_fallback")); +} From ec0a61f748a942a3ec2eb62bdb8c1dfa806cd162 Mon Sep 17 00:00:00 2001 From: Wendell Date: Sat, 16 May 2026 13:41:42 +0800 Subject: [PATCH 2/3] fix clippy useless conversion --- langcodec/src/normalize.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/langcodec/src/normalize.rs b/langcodec/src/normalize.rs index 55e4087..5d31376 100644 --- a/langcodec/src/normalize.rs +++ b/langcodec/src/normalize.rs @@ -67,9 +67,7 @@ fn normalize_codec_in_place( transformed_ids.push(transformed); } - for (entry, transformed_id) in - resource.entries.iter_mut().zip(transformed_ids.into_iter()) - { + for (entry, transformed_id) in resource.entries.iter_mut().zip(transformed_ids) { if entry.id != transformed_id { entry.id = transformed_id; changed = true; From 93c4f0662b6240b803e2aa8c8b36f9dd727a814c Mon Sep 17 00:00:00 2001 From: Wendell Date: Sat, 16 May 2026 13:46:30 +0800 Subject: [PATCH 3/3] fix ci clippy warnings --- langcodec-cli/src/annotate.rs | 9 ++--- langcodec-cli/src/formats.rs | 65 ++++++++++++++--------------------- 2 files changed, 30 insertions(+), 44 deletions(-) diff --git a/langcodec-cli/src/annotate.rs b/langcodec-cli/src/annotate.rs index 9b590c0..8a4b20d 100644 --- a/langcodec-cli/src/annotate.rs +++ b/langcodec-cli/src/annotate.rs @@ -267,16 +267,17 @@ fn run_annotate_with_backend( for request in &requests { match results.get(&request.key) { - Some(Some(annotation)) => { + Some(Some(annotation)) if apply_annotation( &mut codec, annotation_format, &request.key, &annotation.comment, - )? { - changed += 1; - } + )? => + { + changed += 1; } + Some(Some(_)) => {} Some(None) => unmatched += 1, None => {} } diff --git a/langcodec-cli/src/formats.rs b/langcodec-cli/src/formats.rs index 5bf6814..be71db9 100644 --- a/langcodec-cli/src/formats.rs +++ b/langcodec-cli/src/formats.rs @@ -122,51 +122,36 @@ pub fn detect_custom_format(file_path: &str, file_content: &str) -> Option { - // Try to parse as JSON array of Resource objects - if serde_json::from_str::>(file_content).is_ok() { - // Check if it looks like an array of Resource objects - if let Ok(array) = serde_json::from_str::>(file_content) - && !array.is_empty() - { - // Check if the first element has the expected Resource structure - if let Some(first) = array.first() - && let Some(obj) = first.as_object() - && obj.contains_key("metadata") - && obj.contains_key("entries") - { - return Some(CustomFormat::LangcodecResourceArray); - } - } - } + "langcodec" if is_langcodec_resource_array(file_content) => { + Some(CustomFormat::LangcodecResourceArray) } - "json" => { - // Try to parse as JSON object first (JSONLanguageMap) - if serde_json::from_str::(file_content).is_ok() { - // Check if it's an object (not an array) - if let Ok(obj) = serde_json::from_str::< - std::collections::HashMap, - >(file_content) - && !obj.is_empty() - { - return Some(CustomFormat::JSONLanguageMap); - } - // Check if it's an array (JSONArrayLanguageMap) - if serde_json::from_str::>(file_content).is_ok() { - return Some(CustomFormat::JSONArrayLanguageMap); - } - } + "json" if is_json_language_map(file_content) => Some(CustomFormat::JSONLanguageMap), + "json" if serde_json::from_str::>(file_content).is_ok() => { + Some(CustomFormat::JSONArrayLanguageMap) } - "yaml" | "yml" => { - // Try to parse as YAML - if serde_yaml::from_str::(file_content).is_ok() { - return Some(CustomFormat::YAMLLanguageMap); - } + "yaml" | "yml" if serde_yaml::from_str::(file_content).is_ok() => { + Some(CustomFormat::YAMLLanguageMap) } - _ => {} + _ => None, } +} + +fn is_langcodec_resource_array(file_content: &str) -> bool { + let Ok(array) = serde_json::from_str::>(file_content) else { + return false; + }; + let Some(first) = array.first() else { + return false; + }; + + first + .as_object() + .is_some_and(|obj| obj.contains_key("metadata") && obj.contains_key("entries")) +} - None +fn is_json_language_map(file_content: &str) -> bool { + serde_json::from_str::>(file_content) + .is_ok_and(|obj| !obj.is_empty()) } /// Validate custom format file content