diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..a5506e0 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,19 @@ +name: Build packages + +on: + push: + +jobs: + taurus: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup rust + run: rustup update --no-self-update stable + - name: Build Taurus + run: PATH=${{ runner.temp }}/proto/bin:$PATH cargo build + env: + RUST_BACKTRACE: 'full' + - name: Run Tests + run: cargo test \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index f2ac09c..d491bec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2155,6 +2155,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2291,7 +2300,9 @@ dependencies = [ "lapin", "serde", "serde_json", + "tempfile", "tokio", + "toml", "tucana", ] @@ -2432,6 +2443,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900f6c86a685850b1bc9f6223b20125115ee3f31e01207d81655bbcc0aea9231" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10558ed0bd2a1562e630926a2d1f0b98c827da99fabd3fe20920a59642504485" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28391a4201ba7eb1984cfeb6862c0b3ea2cfe23332298967c749dddc0d6cd976" + [[package]] name = "tonic" version = "0.13.0" @@ -2891,6 +2943,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index 704c31a..dfd8f7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,7 @@ serde = "1.0.219" serde_json = "1.0.140" tokio = { version = "1.44.1", features = ["rt-multi-thread"] } tucana = "0.0.20" +toml = "0.8.0" + +[dev-dependencies] +tempfile = "3.19.1" diff --git a/src/locale/code.rs b/src/locale/code.rs new file mode 100644 index 0000000..9d4cb3f --- /dev/null +++ b/src/locale/code.rs @@ -0,0 +1,25 @@ +#[derive(Clone, PartialEq, Debug)] +pub enum CountryCode { + Germany, + UnitedStates, + France, +} + +impl ToString for CountryCode { + fn to_string(&self) -> String { + match self { + CountryCode::Germany => "de-DE".to_string(), + CountryCode::UnitedStates => "en-US".to_string(), + CountryCode::France => "fr-FR".to_string(), + } + } +} + +pub fn code_from_file_name(file_name: String, default: CountryCode) -> CountryCode { + match file_name.as_str() { + "de-DE" => CountryCode::Germany, + "en-US" => CountryCode::UnitedStates, + "fr-FR" => CountryCode::France, + _ => default, + } +} diff --git a/src/locale/locale.rs b/src/locale/locale.rs new file mode 100644 index 0000000..468ed41 --- /dev/null +++ b/src/locale/locale.rs @@ -0,0 +1,505 @@ +use std::{ + collections::HashMap, + fs::{self, read_dir, DirEntry}, +}; + +use serde::Deserialize; +use tucana::shared::Translation; + +use super::code::{code_from_file_name, CountryCode}; + +#[derive(Debug)] +pub struct Locale { + translations: HashMap>, + accepted_locales: Vec, + default_locale: CountryCode, +} + +pub struct TranslationMissingError; + +#[derive(Deserialize)] +pub struct Translations { + #[serde(flatten)] + pub entries: HashMap, +} + +impl Locale { + pub fn default() -> Self { + let path = "./translation"; + let mut dictionary: HashMap> = HashMap::new(); + let mut accepted_locales: Vec = vec![]; + + let entries = match read_dir(path) { + Ok(entries) => entries, + Err(e) => panic!("Failed to read translation directory: {}", e), + }; + + for entry_result in entries { + let entry = match entry_result { + Ok(entry) => entry, + Err(e) => { + eprintln!("Error reading directory entry: {}", e); + continue; + } + }; + + if !is_translation_file(&entry) { + continue; + } + + if let Some((file_name, content)) = read_translation_file(path, &entry) { + let code = code_from_file_name(file_name.clone(), CountryCode::UnitedStates); + accepted_locales.push(code.clone()); + + process_translation_file(&content, &file_name, &code, &mut dictionary); + } + } + + Locale { + translations: dictionary, + accepted_locales, + default_locale: CountryCode::UnitedStates, + } + } + + pub fn new( + path: &str, + accepted_locales: Vec, + default_locale: CountryCode, + ) -> Self { + let mut dictionary = HashMap::new(); + + let entries = match read_dir(path) { + Ok(entries) => entries, + Err(e) => panic!("Failed to read translation directory: {}", e), + }; + + for entry_result in entries { + let entry = match entry_result { + Ok(entry) => entry, + Err(e) => { + eprintln!("Error reading directory entry: {}", e); + continue; + } + }; + + if !is_translation_file(&entry) { + continue; + } + + if let Some((file_name, content)) = read_translation_file(path, &entry) { + let code = code_from_file_name(file_name.clone(), default_locale.clone()); + if !accepted_locales.contains(&code) { + continue; + } + + process_translation_file(&content, &file_name, &code, &mut dictionary); + } + } + + Locale { + translations: dictionary, + accepted_locales, + default_locale, + } + } + + pub fn reduce_to_default(&mut self) { + let code = self.default_locale.to_string(); + for (_, translations) in self.translations.iter_mut() { + translations.retain(|translation| translation.code == code); + } + } + + pub fn reduce_to_accepted(&mut self) { + let codes: Vec = self + .accepted_locales + .iter() + .map(|code| code.to_string()) + .collect(); + for (_, translations) in self.translations.iter_mut() { + translations.retain(|translation| codes.contains(&translation.code)); + } + } + + pub fn get_translations(&self, key: String) -> Option> { + self.translations.get(&key).cloned() + } + + pub fn get_dictionary(&self) -> HashMap> { + self.translations.clone() + } +} + +// Check if entry is a file that we should process +fn is_translation_file(entry: &DirEntry) -> bool { + match entry.metadata() { + Ok(meta) => meta.is_file(), + Err(_) => false, + } +} + +// Read and parse a translation file +fn read_translation_file(path: &str, entry: &DirEntry) -> Option<(String, String)> { + let file_name = entry.file_name(); + + let file_name_str = match file_name.to_str() { + Some(name) => name, + None => return None, + }; + + // Check if it's a .toml file + if !file_name_str.ends_with(".toml") { + return None; + } + + let file_path = format!("{}/{}", path, file_name_str); + let content = match fs::read_to_string(&file_path) { + Ok(content) => content, + Err(e) => { + eprintln!("Failed to read file {}: {}", file_path, e); + return None; + } + }; + + let base_name = file_name_str.strip_suffix(".toml").unwrap().to_string(); + Some((base_name, content)) +} + +// Process the content of a translation file +fn process_translation_file( + content: &str, + file_name: &str, + code: &CountryCode, + dictionary: &mut HashMap>, +) { + match toml::from_str::(content) { + Ok(value) => { + // Process nested TOML by flattening it + let flattened = flatten_toml(&value, ""); + add_translations_to_dictionary(flattened, code, dictionary); + } + Err(err) => { + eprintln!("Warning: Error parsing '{}': {}", file_name, err); + } + } +} + +// Add translations to the dictionary +fn add_translations_to_dictionary( + flattened: HashMap, + code: &CountryCode, + dictionary: &mut HashMap>, +) { + for (key, entry) in flattened { + let translation = Translation { + code: code.to_string(), + content: entry, + }; + + dictionary + .entry(key) + .or_insert_with(Vec::new) + .push(translation); + } +} + +// Helper function to flatten nested TOML structures +fn flatten_toml(value: &toml::Value, prefix: &str) -> HashMap { + let mut result = HashMap::new(); + + match value { + toml::Value::Table(table) => { + for (key, val) in table { + let new_prefix = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + + match val { + toml::Value::Table(_) => { + let nested = flatten_toml(val, &new_prefix); + result.extend(nested); + } + _ => { + extract_value_to_string(val, &new_prefix, &mut result); + } + } + } + } + _ => { + if !prefix.is_empty() { + extract_value_to_string(value, prefix, &mut result); + } + } + } + + result +} + +// Extract a TOML value into a string +fn extract_value_to_string(val: &toml::Value, key: &str, result: &mut HashMap) { + if let Some(str_val) = val.as_str() { + result.insert(key.to_string(), str_val.to_string()); + } else if let Some(string_val) = val + .to_string() + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + { + result.insert(key.to_string(), string_val.to_string()); + } else { + result.insert(key.to_string(), val.to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use std::io::Write; + use std::path::Path; + use tempfile::tempdir; + + fn create_test_translation_file( + dir: &Path, + filename: &str, + content: &str, + ) -> std::io::Result<()> { + let file_path = dir.join(filename); + let mut file = File::create(file_path)?; + file.write_all(content.as_bytes())?; + Ok(()) + } + + #[test] + fn test_flatten_toml() { + let toml_str = r#" + key1 = "value1" + + [section1] + key2 = "value2" + + [section1.subsection] + key3 = "value3" + "#; + + let value: toml::Value = toml::from_str(toml_str).unwrap(); + let flattened = flatten_toml(&value, ""); + + assert_eq!(flattened.get("key1"), Some(&"value1".to_string())); + assert_eq!(flattened.get("section1.key2"), Some(&"value2".to_string())); + assert_eq!( + flattened.get("section1.subsection.key3"), + Some(&"value3".to_string()) + ); + } + + #[test] + fn test_add_translations_to_dictionary() { + let mut flattened = HashMap::new(); + flattened.insert("key1".to_string(), "value1".to_string()); + flattened.insert("key2".to_string(), "value2".to_string()); + + let code = CountryCode::UnitedStates; + let mut dictionary = HashMap::new(); + + add_translations_to_dictionary(flattened, &code, &mut dictionary); + + assert_eq!(dictionary.len(), 2); + assert_eq!(dictionary["key1"][0].content, "value1"); + assert_eq!(dictionary["key1"][0].code, code.to_string()); + assert_eq!(dictionary["key2"][0].content, "value2"); + assert_eq!(dictionary["key2"][0].code, code.to_string()); + } + + #[test] + fn test_reduce_to_default_empty() { + let mut translations = HashMap::new(); + + let key = "greeting".to_string(); + let mut values = Vec::new(); + + values.push(Translation { + code: CountryCode::Germany.to_string(), + content: "Hallo".to_string(), + }); + + values.push(Translation { + code: CountryCode::France.to_string(), + content: "Bonjour".to_string(), + }); + + translations.insert(key.clone(), values); + + let mut locale = Locale { + translations, + accepted_locales: vec![CountryCode::UnitedStates, CountryCode::France], + default_locale: CountryCode::UnitedStates, + }; + + locale.reduce_to_default(); + + let translations = locale.get_translations(key); + assert!(translations.is_some()); + assert_eq!(translations.unwrap().len(), 0); + } + + #[test] + fn test_reduce_to_default() { + let mut translations = HashMap::new(); + + let key = "greeting".to_string(); + let mut values = Vec::new(); + + values.push(Translation { + code: CountryCode::Germany.to_string(), + content: "Hallo".to_string(), + }); + + values.push(Translation { + code: CountryCode::France.to_string(), + content: "Bonjour".to_string(), + }); + + values.push(Translation { + code: CountryCode::UnitedStates.to_string(), + content: "Hello".to_string(), + }); + + translations.insert(key.clone(), values); + + let mut locale = Locale { + translations, + accepted_locales: vec![CountryCode::UnitedStates, CountryCode::France], + default_locale: CountryCode::UnitedStates, + }; + + locale.reduce_to_default(); + + let translations = locale.get_translations(key); + assert!(translations.is_some()); + assert_eq!(translations.unwrap().len(), 1); + } + + #[test] + fn test_reduce_to_accepted() { + let mut translations = HashMap::new(); + + let key = "greeting".to_string(); + let mut values = Vec::new(); + + values.push(Translation { + code: CountryCode::UnitedStates.to_string(), + content: "Hello".to_string(), + }); + + values.push(Translation { + code: CountryCode::France.to_string(), + content: "Bonjour".to_string(), + }); + + values.push(Translation { + code: CountryCode::Germany.to_string(), + content: "Hallo".to_string(), + }); + + translations.insert(key.clone(), values); + + let mut locale = Locale { + translations, + accepted_locales: vec![CountryCode::UnitedStates, CountryCode::France], + default_locale: CountryCode::UnitedStates, + }; + + locale.reduce_to_accepted(); + + let translations = locale.get_translations(key); + assert!(translations.is_some()); + assert_eq!(translations.unwrap().len(), 2); + } + + #[test] + fn test_reduce_to_accepted_empty() { + let mut translations = HashMap::new(); + + let key = "greeting".to_string(); + let mut values = Vec::new(); + + values.push(Translation { + code: CountryCode::UnitedStates.to_string(), + content: "Hello".to_string(), + }); + + values.push(Translation { + code: CountryCode::France.to_string(), + content: "Bonjour".to_string(), + }); + + translations.insert(key.clone(), values); + + let mut locale = Locale { + translations, + accepted_locales: vec![CountryCode::Germany], + default_locale: CountryCode::UnitedStates, + }; + + locale.reduce_to_accepted(); + + let translations = locale.get_translations(key); + assert!(translations.is_some()); + assert_eq!(translations.unwrap().len(), 0); + } + + #[test] + fn test_locale_new_with_files() { + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + + // Create test translation files + let en_file = create_test_translation_file( + temp_path, + "en_US.toml", + r#" + welcome = "Welcome" + goodbye = "Goodbye" + "#, + ); + + assert!(en_file.is_ok()); + + let fr_file = create_test_translation_file( + temp_path, + "fr_FR.toml", + r#" + welcome = "Bienvenue" + goodbye = "Au revoir" + "#, + ); + + assert!(fr_file.is_ok()); + + let locale = Locale::new( + temp_path.to_str().unwrap(), + vec![CountryCode::UnitedStates, CountryCode::France], + CountryCode::UnitedStates, + ); + + assert_eq!(locale.accepted_locales.len(), 2); + assert!(locale.accepted_locales.contains(&CountryCode::UnitedStates)); + assert!(locale.accepted_locales.contains(&CountryCode::France)); + assert_eq!( + locale.default_locale, + CountryCode::UnitedStates, + "Not the same default locale" + ); + + // Test that translations were loaded correctly + let welcome_translations = locale.get_translations("welcome".to_string()).unwrap(); + let goodbye_translations = locale.get_translations("goodbye".to_string()).unwrap(); + let empty_translations = locale.get_translations("empty".to_string()); + assert_eq!(welcome_translations.len(), 2); + assert_eq!(goodbye_translations.len(), 2); + assert!(empty_translations.is_none()); + } +} diff --git a/src/locale/mod.rs b/src/locale/mod.rs new file mode 100644 index 0000000..3522ae4 --- /dev/null +++ b/src/locale/mod.rs @@ -0,0 +1,2 @@ +pub mod code; +pub mod locale; diff --git a/src/main.rs b/src/main.rs index fdc10d5..f220cdf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,7 @@ +pub mod locale; + use code0_flow::flow_queue::service::{Message, RabbitmqClient}; +use locale::locale::Locale; use std::sync::Arc; fn handle_message(message: Message) -> Result { @@ -13,6 +16,7 @@ fn handle_message(message: Message) -> Result { #[tokio::main] async fn main() { + let locale = Locale::default(); let rabbitmq_client = Arc::new(RabbitmqClient::new("amqp://localhost:5672").await); // Receive messages from the send_queue diff --git a/translation/de-DE.toml b/translation/de-DE.toml new file mode 100644 index 0000000..cb12939 --- /dev/null +++ b/translation/de-DE.toml @@ -0,0 +1,6 @@ +greeting = "Guten Tag!" +farewell = "Auf Wiedersehen!" + +[debug] +enabled = "Ja" +level = "Information" diff --git a/translation/en-US.toml b/translation/en-US.toml new file mode 100644 index 0000000..e1c2a58 --- /dev/null +++ b/translation/en-US.toml @@ -0,0 +1,6 @@ +greeting = "Hello There!" +farewell = "Goodbye!" + +[debug] +enabled = "yes" +level = "information"