langcodec/codec.rs
1/// This module provides the `Codec` struct and associated functionality for reading,
2/// writing, caching, and loading localized resource files in various formats.
3/// The `Codec` struct manages a collection of `Resource` instances and supports
4/// format inference, language detection from file paths, and serialization.
5///
6/// The module handles different localization file formats such as Apple `.strings`,
7/// Android XML strings, and `.xcstrings`, providing methods to read from files by type
8/// or extension, write resources back to files, and cache resources to JSON.
9///
10use crate::formats::CSVRecord;
11use crate::{
12 error::Error,
13 formats::*,
14 traits::Parser,
15 types::{Entry, Resource},
16};
17use std::path::Path;
18
19/// Represents a collection of localized resources and provides methods to read,
20/// write, cache, and load these resources.
21#[derive(Debug)]
22pub struct Codec {
23 /// The collection of resources managed by this codec.
24 pub resources: Vec<Resource>,
25}
26
27impl Default for Codec {
28 fn default() -> Self {
29 Codec::new()
30 }
31}
32
33impl Codec {
34 /// Creates a new, empty `Codec`.
35 ///
36 /// # Returns
37 ///
38 /// A new `Codec` instance with no resources.
39 pub fn new() -> Self {
40 Codec {
41 resources: Vec::new(),
42 }
43 }
44
45 /// Creates a new `CodecBuilder` for fluent construction.
46 ///
47 /// This method returns a builder that allows you to chain method calls
48 /// to add resources from files and then build the final `Codec` instance.
49 ///
50 /// # Example
51 ///
52 /// ```rust,no_run
53 /// use langcodec::Codec;
54 ///
55 /// let codec = Codec::builder()
56 /// .add_file("en.strings")?
57 /// .add_file("fr.strings")?
58 /// .build();
59 /// # Ok::<(), langcodec::Error>(())
60 /// ```
61 ///
62 /// # Returns
63 ///
64 /// Returns a new `CodecBuilder` instance.
65 pub fn builder() -> crate::builder::CodecBuilder {
66 crate::builder::CodecBuilder::new()
67 }
68
69 /// Returns an iterator over all resources.
70 pub fn iter(&self) -> std::slice::Iter<Resource> {
71 self.resources.iter()
72 }
73
74 /// Returns a mutable iterator over all resources.
75 pub fn iter_mut(&mut self) -> std::slice::IterMut<Resource> {
76 self.resources.iter_mut()
77 }
78
79 /// Finds a resource by its language code, if present.
80 pub fn get_by_language(&self, lang: &str) -> Option<&Resource> {
81 self.resources
82 .iter()
83 .find(|res| res.metadata.language == lang)
84 }
85
86 /// Finds a mutable resource by its language code, if present.
87 pub fn get_mut_by_language(&mut self, lang: &str) -> Option<&mut Resource> {
88 self.resources
89 .iter_mut()
90 .find(|res| res.metadata.language == lang)
91 }
92
93 /// Adds a new resource to the collection.
94 pub fn add_resource(&mut self, resource: Resource) {
95 self.resources.push(resource);
96 }
97
98 // ===== HIGH-LEVEL MODIFICATION METHODS =====
99
100 /// Finds an entry by its key across all languages.
101 ///
102 /// Returns an iterator over all resources and their matching entries.
103 ///
104 /// # Arguments
105 ///
106 /// * `key` - The entry key to search for
107 ///
108 /// # Returns
109 ///
110 /// An iterator yielding `(&Resource, &Entry)` pairs for all matching entries.
111 ///
112 /// # Example
113 ///
114 /// ```rust
115 /// use langcodec::Codec;
116 ///
117 /// let mut codec = Codec::new();
118 /// // ... load resources ...
119 ///
120 /// for (resource, entry) in codec.find_entries("welcome_message") {
121 /// println!("{}: {}", resource.metadata.language, entry.value);
122 /// }
123 /// ```
124 pub fn find_entries(&self, key: &str) -> Vec<(&Resource, &Entry)> {
125 let mut results = Vec::new();
126 for resource in &self.resources {
127 for entry in &resource.entries {
128 if entry.id == key {
129 results.push((resource, entry));
130 }
131 }
132 }
133 results
134 }
135
136 /// Finds an entry by its key in a specific language.
137 ///
138 /// # Arguments
139 ///
140 /// * `key` - The entry key to search for
141 /// * `language` - The language code (e.g., "en", "fr")
142 ///
143 /// # Returns
144 ///
145 /// `Some(&Entry)` if found, `None` otherwise.
146 ///
147 /// # Example
148 ///
149 /// ```rust
150 /// use langcodec::Codec;
151 ///
152 /// let mut codec = Codec::new();
153 /// // ... load resources ...
154 ///
155 /// if let Some(entry) = codec.find_entry("welcome_message", "en") {
156 /// println!("English welcome: {}", entry.value);
157 /// }
158 /// ```
159 pub fn find_entry(&self, key: &str, language: &str) -> Option<&Entry> {
160 self.get_by_language(language)?
161 .entries
162 .iter()
163 .find(|entry| entry.id == key)
164 }
165
166 /// Finds a mutable entry by its key in a specific language.
167 ///
168 /// # Arguments
169 ///
170 /// * `key` - The entry key to search for
171 /// * `language` - The language code (e.g., "en", "fr")
172 ///
173 /// # Returns
174 ///
175 /// `Some(&mut Entry)` if found, `None` otherwise.
176 ///
177 /// # Example
178 ///
179 /// ```rust
180 /// use langcodec::Codec;
181 /// use langcodec::types::Translation;
182 ///
183 /// let mut codec = Codec::new();
184 /// // ... load resources ...
185 ///
186 /// if let Some(entry) = codec.find_entry_mut("welcome_message", "en") {
187 /// entry.value = Translation::Singular("Hello, World!".to_string());
188 /// entry.status = langcodec::types::EntryStatus::Translated;
189 /// }
190 /// ```
191 pub fn find_entry_mut(&mut self, key: &str, language: &str) -> Option<&mut Entry> {
192 self.get_mut_by_language(language)?
193 .entries
194 .iter_mut()
195 .find(|entry| entry.id == key)
196 }
197
198 /// Updates a translation for a specific key and language.
199 ///
200 /// # Arguments
201 ///
202 /// * `key` - The entry key to update
203 /// * `language` - The language code (e.g., "en", "fr")
204 /// * `translation` - The new translation value
205 /// * `status` - Optional new status (defaults to `Translated`)
206 ///
207 /// # Returns
208 ///
209 /// `Ok(())` if the entry was found and updated, `Err` if not found.
210 ///
211 /// # Example
212 ///
213 /// ```rust
214 /// use langcodec::{Codec, types::{Translation, EntryStatus}};
215 ///
216 /// let mut codec = Codec::new();
217 /// // Add an entry first
218 /// codec.add_entry("welcome", "en", Translation::Singular("Hello".to_string()), None, None)?;
219 ///
220 /// codec.update_translation(
221 /// "welcome",
222 /// "en",
223 /// Translation::Singular("Hello, World!".to_string()),
224 /// Some(EntryStatus::Translated)
225 /// )?;
226 /// # Ok::<(), langcodec::Error>(())
227 /// ```
228 pub fn update_translation(
229 &mut self,
230 key: &str,
231 language: &str,
232 translation: crate::types::Translation,
233 status: Option<crate::types::EntryStatus>,
234 ) -> Result<(), Error> {
235 if let Some(entry) = self.find_entry_mut(key, language) {
236 entry.value = translation;
237 if let Some(new_status) = status {
238 entry.status = new_status;
239 }
240 Ok(())
241 } else {
242 Err(Error::InvalidResource(format!(
243 "Entry '{}' not found in language '{}'",
244 key, language
245 )))
246 }
247 }
248
249 /// Adds a new entry to a specific language.
250 ///
251 /// If the language doesn't exist, it will be created automatically.
252 ///
253 /// # Arguments
254 ///
255 /// * `key` - The entry key
256 /// * `language` - The language code (e.g., "en", "fr")
257 /// * `translation` - The translation value
258 /// * `comment` - Optional comment for translators
259 /// * `status` - Optional status (defaults to `New`)
260 ///
261 /// # Returns
262 ///
263 /// `Ok(())` if the entry was added successfully.
264 ///
265 /// # Example
266 ///
267 /// ```rust
268 /// use langcodec::{Codec, types::{Translation, EntryStatus}};
269 ///
270 /// let mut codec = Codec::new();
271 ///
272 /// codec.add_entry(
273 /// "new_message",
274 /// "en",
275 /// Translation::Singular("This is a new message".to_string()),
276 /// Some("This is a new message for users".to_string()),
277 /// Some(EntryStatus::New)
278 /// )?;
279 /// # Ok::<(), langcodec::Error>(())
280 /// ```
281 pub fn add_entry(
282 &mut self,
283 key: &str,
284 language: &str,
285 translation: crate::types::Translation,
286 comment: Option<String>,
287 status: Option<crate::types::EntryStatus>,
288 ) -> Result<(), Error> {
289 // Find or create the resource for this language
290 let resource = if let Some(resource) = self.get_mut_by_language(language) {
291 resource
292 } else {
293 // Create a new resource for this language
294 let new_resource = crate::types::Resource {
295 metadata: crate::types::Metadata {
296 language: language.to_string(),
297 domain: "".to_string(),
298 custom: std::collections::HashMap::new(),
299 },
300 entries: Vec::new(),
301 };
302 self.add_resource(new_resource);
303 self.get_mut_by_language(language).unwrap()
304 };
305
306 let entry = crate::types::Entry {
307 id: key.to_string(),
308 value: translation,
309 comment,
310 status: status.unwrap_or(crate::types::EntryStatus::New),
311 custom: std::collections::HashMap::new(),
312 };
313 resource.add_entry(entry);
314 Ok(())
315 }
316
317 /// Removes an entry from a specific language.
318 ///
319 /// # Arguments
320 ///
321 /// * `key` - The entry key to remove
322 /// * `language` - The language code (e.g., "en", "fr")
323 ///
324 /// # Returns
325 ///
326 /// `Ok(())` if the entry was found and removed, `Err` if not found.
327 ///
328 /// # Example
329 ///
330 /// ```rust
331 /// use langcodec::{Codec, types::{Translation, EntryStatus}};
332 ///
333 /// let mut codec = Codec::new();
334 /// // Add a resource first
335 /// codec.add_entry("test_key", "en", Translation::Singular("Test".to_string()), None, None)?;
336 ///
337 /// // Now remove it
338 /// codec.remove_entry("test_key", "en")?;
339 /// # Ok::<(), langcodec::Error>(())
340 /// ```
341 pub fn remove_entry(&mut self, key: &str, language: &str) -> Result<(), Error> {
342 if let Some(resource) = self.get_mut_by_language(language) {
343 let initial_len = resource.entries.len();
344 resource.entries.retain(|entry| entry.id != key);
345
346 if resource.entries.len() == initial_len {
347 Err(Error::InvalidResource(format!(
348 "Entry '{}' not found in language '{}'",
349 key, language
350 )))
351 } else {
352 Ok(())
353 }
354 } else {
355 Err(Error::InvalidResource(format!(
356 "Language '{}' not found",
357 language
358 )))
359 }
360 }
361
362 /// Copies an entry from one language to another.
363 ///
364 /// This is useful for creating new translations based on existing ones.
365 ///
366 /// # Arguments
367 ///
368 /// * `key` - The entry key to copy
369 /// * `from_language` - The source language
370 /// * `to_language` - The target language
371 /// * `update_status` - Whether to update the status to `New` in the target language
372 ///
373 /// # Returns
374 ///
375 /// `Ok(())` if the entry was copied successfully, `Err` if not found.
376 ///
377 /// # Example
378 ///
379 /// ```rust
380 /// use langcodec::{Codec, types::{Translation, EntryStatus}};
381 ///
382 /// let mut codec = Codec::new();
383 /// // Add source entry first
384 /// codec.add_entry("welcome", "en", Translation::Singular("Hello".to_string()), None, None)?;
385 ///
386 /// // Copy English entry to French as a starting point
387 /// codec.copy_entry("welcome", "en", "fr", true)?;
388 /// # Ok::<(), langcodec::Error>(())
389 /// ```
390 pub fn copy_entry(
391 &mut self,
392 key: &str,
393 from_language: &str,
394 to_language: &str,
395 update_status: bool,
396 ) -> Result<(), Error> {
397 let source_entry = self.find_entry(key, from_language).ok_or_else(|| {
398 Error::InvalidResource(format!(
399 "Entry '{}' not found in source language '{}'",
400 key, from_language
401 ))
402 })?;
403
404 let mut new_entry = source_entry.clone();
405 if update_status {
406 new_entry.status = crate::types::EntryStatus::New;
407 }
408
409 // Find or create the target resource
410 let target_resource = if let Some(resource) = self.get_mut_by_language(to_language) {
411 resource
412 } else {
413 // Create a new resource for the target language
414 let new_resource = crate::types::Resource {
415 metadata: crate::types::Metadata {
416 language: to_language.to_string(),
417 domain: "".to_string(),
418 custom: std::collections::HashMap::new(),
419 },
420 entries: Vec::new(),
421 };
422 self.add_resource(new_resource);
423 self.get_mut_by_language(to_language).unwrap()
424 };
425
426 // Remove existing entry if it exists
427 target_resource.entries.retain(|entry| entry.id != key);
428 target_resource.add_entry(new_entry);
429 Ok(())
430 }
431
432 /// Gets all languages available in the codec.
433 ///
434 /// # Returns
435 ///
436 /// An iterator over all language codes.
437 ///
438 /// # Example
439 ///
440 /// ```rust
441 /// use langcodec::Codec;
442 ///
443 /// let codec = Codec::new();
444 /// // ... load resources ...
445 ///
446 /// for language in codec.languages() {
447 /// println!("Available language: {}", language);
448 /// }
449 /// ```
450 pub fn languages(&self) -> impl Iterator<Item = &str> {
451 self.resources.iter().map(|r| r.metadata.language.as_str())
452 }
453
454 /// Gets all unique entry keys across all languages.
455 ///
456 /// # Returns
457 ///
458 /// An iterator over all unique entry keys.
459 ///
460 /// # Example
461 ///
462 /// ```rust
463 /// use langcodec::Codec;
464 ///
465 /// let codec = Codec::new();
466 /// // ... load resources ...
467 ///
468 /// for key in codec.all_keys() {
469 /// println!("Available key: {}", key);
470 /// }
471 /// ```
472 pub fn all_keys(&self) -> impl Iterator<Item = &str> {
473 use std::collections::HashSet;
474
475 let mut keys = HashSet::new();
476 for resource in &self.resources {
477 for entry in &resource.entries {
478 keys.insert(entry.id.as_str());
479 }
480 }
481 keys.into_iter()
482 }
483
484 /// Checks if an entry exists in a specific language.
485 ///
486 /// # Arguments
487 ///
488 /// * `key` - The entry key to check
489 /// * `language` - The language code (e.g., "en", "fr")
490 ///
491 /// # Returns
492 ///
493 /// `true` if the entry exists, `false` otherwise.
494 ///
495 /// # Example
496 ///
497 /// ```rust
498 /// use langcodec::Codec;
499 ///
500 /// let codec = Codec::new();
501 /// // ... load resources ...
502 ///
503 /// if codec.has_entry("welcome_message", "en") {
504 /// println!("English welcome message exists");
505 /// }
506 /// ```
507 pub fn has_entry(&self, key: &str, language: &str) -> bool {
508 self.find_entry(key, language).is_some()
509 }
510
511 /// Gets the count of entries in a specific language.
512 ///
513 /// # Arguments
514 ///
515 /// * `language` - The language code (e.g., "en", "fr")
516 ///
517 /// # Returns
518 ///
519 /// The number of entries in the specified language, or 0 if the language doesn't exist.
520 ///
521 /// # Example
522 ///
523 /// ```rust
524 /// use langcodec::Codec;
525 ///
526 /// let codec = Codec::new();
527 /// // ... load resources ...
528 ///
529 /// let count = codec.entry_count("en");
530 /// println!("English has {} entries", count);
531 /// ```
532 pub fn entry_count(&self, language: &str) -> usize {
533 self.get_by_language(language)
534 .map(|r| r.entries.len())
535 .unwrap_or(0)
536 }
537
538 /// Validates the codec for common issues.
539 ///
540 /// # Returns
541 ///
542 /// `Ok(())` if validation passes, `Err(Error)` with details if validation fails.
543 ///
544 /// # Example
545 ///
546 /// ```rust
547 /// use langcodec::Codec;
548 ///
549 /// let mut codec = Codec::new();
550 /// // ... add resources ...
551 ///
552 /// if let Err(validation_error) = codec.validate() {
553 /// eprintln!("Validation failed: {}", validation_error);
554 /// }
555 /// ```
556 pub fn validate(&self) -> Result<(), Error> {
557 // Check for empty resources
558 if self.resources.is_empty() {
559 return Err(Error::InvalidResource("No resources found".to_string()));
560 }
561
562 // Check for duplicate languages
563 let mut languages = std::collections::HashSet::new();
564 for resource in &self.resources {
565 if !languages.insert(&resource.metadata.language) {
566 return Err(Error::InvalidResource(format!(
567 "Duplicate language found: {}",
568 resource.metadata.language
569 )));
570 }
571 }
572
573 // Check for empty resources
574 for resource in &self.resources {
575 if resource.entries.is_empty() {
576 return Err(Error::InvalidResource(format!(
577 "Resource for language '{}' has no entries",
578 resource.metadata.language
579 )));
580 }
581 }
582
583 Ok(())
584 }
585
586 /// Merges multiple resources into a single resource with conflict resolution.
587 ///
588 /// This function merges resources that all have the same language.
589 /// Only entries with the same ID are treated as conflicts.
590 ///
591 /// # Arguments
592 ///
593 /// * `resources` - The resources to merge (must all have the same language)
594 /// * `conflict_strategy` - How to handle conflicting entries (same ID)
595 ///
596 /// # Returns
597 ///
598 /// A merged resource with all entries from the input resources.
599 ///
600 /// # Errors
601 ///
602 /// Returns an error if:
603 /// - No resources are provided
604 /// - Resources have different languages (each Resource represents one language)
605 ///
606 /// # Example
607 ///
608 /// ```rust
609 /// use langcodec::{Codec, types::{Resource, Metadata, Entry, Translation, EntryStatus}};
610 ///
611 /// let mut codec = Codec::new();
612 /// // ... load resources ...
613 ///
614 /// // Create some sample resources for merging
615 /// let resource1 = Resource {
616 /// metadata: Metadata {
617 /// language: "en".to_string(),
618 /// domain: "domain".to_string(),
619 /// custom: std::collections::HashMap::new(),
620 /// },
621 /// entries: vec![
622 /// Entry {
623 /// id: "hello".to_string(),
624 /// value: Translation::Singular("Hello".to_string()),
625 /// comment: None,
626 /// status: EntryStatus::Translated,
627 /// custom: std::collections::HashMap::new(),
628 /// }
629 /// ],
630 /// };
631 ///
632 /// let merged = Codec::merge_resources(
633 /// &[resource1],
634 /// langcodec::types::ConflictStrategy::Last
635 /// )?;
636 /// # Ok::<(), langcodec::Error>(())
637 /// ```
638 pub fn merge_resources(
639 resources: &[Resource],
640 conflict_strategy: crate::types::ConflictStrategy,
641 ) -> Result<Resource, Error> {
642 if resources.is_empty() {
643 return Err(Error::InvalidResource("No resources to merge".to_string()));
644 }
645
646 // Validate that all resources have the same language
647 let first_language = &resources[0].metadata.language;
648 for (i, resource) in resources.iter().enumerate() {
649 if resource.metadata.language != *first_language {
650 return Err(Error::InvalidResource(format!(
651 "Cannot merge resources with different languages: resource {} has language '{}', but first resource has language '{}'",
652 i + 1,
653 resource.metadata.language,
654 first_language
655 )));
656 }
657 }
658
659 let mut merged = resources[0].clone();
660 let mut all_entries = std::collections::HashMap::new();
661
662 // Collect all entries from all resources
663 for resource in resources {
664 for entry in &resource.entries {
665 // Use the original entry ID for conflict resolution
666 // Since all resources have the same language, conflicts are based on ID only
667 match conflict_strategy {
668 crate::types::ConflictStrategy::First => {
669 all_entries
670 .entry(&entry.id)
671 .or_insert_with(|| entry.clone());
672 }
673 crate::types::ConflictStrategy::Last => {
674 all_entries.insert(&entry.id, entry.clone());
675 }
676 crate::types::ConflictStrategy::Skip => {
677 if all_entries.contains_key(&entry.id) {
678 // Remove the existing entry and skip this one too
679 all_entries.remove(&entry.id);
680 continue;
681 }
682 all_entries.insert(&entry.id, entry.clone());
683 }
684 }
685 }
686 }
687
688 // Convert back to vector and sort by key for consistent output
689 merged.entries = all_entries.into_values().collect();
690 merged.entries.sort_by(|a, b| a.id.cmp(&b.id));
691
692 Ok(merged)
693 }
694
695 /// Writes a resource to a file with automatic format detection.
696 ///
697 /// # Arguments
698 ///
699 /// * `resource` - The resource to write
700 /// * `output_path` - The output file path
701 ///
702 /// # Returns
703 ///
704 /// `Ok(())` on success, `Err(Error)` on failure.
705 ///
706 /// # Example
707 ///
708 /// ```rust,no_run
709 /// use langcodec::{Codec, types::{Resource, Metadata, Entry, Translation, EntryStatus}};
710 ///
711 /// let resource = Resource {
712 /// metadata: Metadata {
713 /// language: "en".to_string(),
714 /// domain: "domain".to_string(),
715 /// custom: std::collections::HashMap::new(),
716 /// },
717 /// entries: vec![],
718 /// };
719 /// Codec::write_resource_to_file(&resource, "output.strings")?;
720 /// # Ok::<(), langcodec::Error>(())
721 /// ```
722 pub fn write_resource_to_file(resource: &Resource, output_path: &str) -> Result<(), Error> {
723 use crate::formats::{AndroidStringsFormat, CSVRecord, StringsFormat, XcstringsFormat};
724 use std::path::Path;
725
726 // Infer format from output path
727 let format_type = infer_format_from_extension(output_path).ok_or_else(|| {
728 Error::InvalidResource(format!(
729 "Cannot infer format from output path: {}",
730 output_path
731 ))
732 })?;
733
734 match format_type {
735 crate::formats::FormatType::AndroidStrings(_) => {
736 AndroidStringsFormat::from(resource.clone())
737 .write_to(Path::new(output_path))
738 .map_err(|e| {
739 Error::conversion_error(
740 format!("Error writing AndroidStrings output: {}", e),
741 None,
742 )
743 })
744 }
745 crate::formats::FormatType::Strings(_) => StringsFormat::try_from(resource.clone())
746 .and_then(|f| f.write_to(Path::new(output_path)))
747 .map_err(|e| {
748 Error::conversion_error(format!("Error writing Strings output: {}", e), None)
749 }),
750 crate::formats::FormatType::Xcstrings => {
751 XcstringsFormat::try_from(vec![resource.clone()])
752 .and_then(|f| f.write_to(Path::new(output_path)))
753 .map_err(|e| {
754 Error::conversion_error(
755 format!("Error writing Xcstrings output: {}", e),
756 None,
757 )
758 })
759 }
760 crate::formats::FormatType::CSV(_) => Vec::<CSVRecord>::try_from(resource.clone())
761 .and_then(|f| f.write_to(Path::new(output_path)))
762 .map_err(|e| {
763 Error::conversion_error(format!("Error writing CSV output: {}", e), None)
764 }),
765 }
766 }
767
768 /// Converts a vector of resources to a specific output format.
769 ///
770 /// # Arguments
771 ///
772 /// * `resources` - The resources to convert
773 /// * `output_path` - The output file path
774 /// * `output_format` - The target format
775 ///
776 /// # Returns
777 ///
778 /// `Ok(())` on success, `Err(Error)` on failure.
779 ///
780 /// # Example
781 ///
782 /// ```rust, no_run
783 /// use langcodec::{Codec, types::{Resource, Metadata, Entry, Translation, EntryStatus}, formats::FormatType};
784 ///
785 /// let resources = vec![Resource {
786 /// metadata: Metadata {
787 /// language: "en".to_string(),
788 /// domain: "domain".to_string(),
789 /// custom: std::collections::HashMap::new(),
790 /// },
791 /// entries: vec![],
792 /// }];
793 /// Codec::convert_resources_to_format(
794 /// resources,
795 /// "output.strings",
796 /// FormatType::Strings(None)
797 /// )?;
798 /// # Ok::<(), langcodec::Error>(())
799 /// ```
800 pub fn convert_resources_to_format(
801 resources: Vec<Resource>,
802 output_path: &str,
803 output_format: crate::formats::FormatType,
804 ) -> Result<(), Error> {
805 use crate::formats::{AndroidStringsFormat, CSVRecord, StringsFormat, XcstringsFormat};
806 use std::path::Path;
807
808 match output_format {
809 crate::formats::FormatType::AndroidStrings(_) => {
810 if let Some(resource) = resources.first() {
811 AndroidStringsFormat::from(resource.clone())
812 .write_to(Path::new(output_path))
813 .map_err(|e| {
814 Error::conversion_error(
815 format!("Error writing AndroidStrings output: {}", e),
816 None,
817 )
818 })
819 } else {
820 Err(Error::InvalidResource(
821 "No resources to convert".to_string(),
822 ))
823 }
824 }
825 crate::formats::FormatType::Strings(_) => {
826 if let Some(resource) = resources.first() {
827 StringsFormat::try_from(resource.clone())
828 .and_then(|f| f.write_to(Path::new(output_path)))
829 .map_err(|e| {
830 Error::conversion_error(
831 format!("Error writing Strings output: {}", e),
832 None,
833 )
834 })
835 } else {
836 Err(Error::InvalidResource(
837 "No resources to convert".to_string(),
838 ))
839 }
840 }
841 crate::formats::FormatType::Xcstrings => XcstringsFormat::try_from(resources)
842 .and_then(|f| f.write_to(Path::new(output_path)))
843 .map_err(|e| {
844 Error::conversion_error(format!("Error writing Xcstrings output: {}", e), None)
845 }),
846 crate::formats::FormatType::CSV(_) => {
847 if let Some(resource) = resources.first() {
848 Vec::<CSVRecord>::try_from(resource.clone())
849 .and_then(|f| f.write_to(Path::new(output_path)))
850 .map_err(|e| {
851 Error::conversion_error(
852 format!("Error writing CSV output: {}", e),
853 None,
854 )
855 })
856 } else {
857 Err(Error::InvalidResource(
858 "No resources to convert".to_string(),
859 ))
860 }
861 }
862 }
863 }
864
865 /// Reads a resource file given its path and explicit format type.
866 ///
867 /// # Parameters
868 /// - `path`: Path to the resource file.
869 /// - `format_type`: The format type of the resource file.
870 ///
871 /// # Returns
872 ///
873 /// `Ok(())` if the file was successfully read and resources loaded,
874 /// or an `Error` otherwise.
875 pub fn read_file_by_type<P: AsRef<Path>>(
876 &mut self,
877 path: P,
878 format_type: FormatType,
879 ) -> Result<(), Error> {
880 let language = infer_language_from_path(&path, &format_type)?;
881
882 let domain = path
883 .as_ref()
884 .file_stem()
885 .and_then(|s| s.to_str())
886 .unwrap_or_default()
887 .to_string();
888 let path = path.as_ref();
889
890 let mut new_resources = match &format_type {
891 FormatType::Strings(_) => {
892 vec![Resource::from(StringsFormat::read_from(path)?)]
893 }
894 FormatType::AndroidStrings(_) => {
895 vec![Resource::from(AndroidStringsFormat::read_from(path)?)]
896 }
897 FormatType::Xcstrings => Vec::<Resource>::try_from(XcstringsFormat::read_from(path)?)?,
898 FormatType::CSV(_) => {
899 vec![Resource::from(Vec::<CSVRecord>::read_from(path)?)]
900 }
901 };
902
903 for new_resource in &mut new_resources {
904 if let Some(ref lang) = language {
905 new_resource.metadata.language = lang.clone();
906 }
907 new_resource.metadata.domain = domain.clone();
908 new_resource
909 .metadata
910 .custom
911 .insert("format".to_string(), format_type.to_string());
912 }
913 self.resources.append(&mut new_resources);
914
915 Ok(())
916 }
917
918 /// Reads a resource file by inferring its format from the file extension.
919 /// Optionally infers language from the path if not provided.
920 ///
921 /// # Parameters
922 /// - `path`: Path to the resource file.
923 /// - `lang`: Optional language code to use.
924 ///
925 /// # Returns
926 ///
927 /// `Ok(())` if the file was successfully read,
928 /// or an `Error` if the format is unsupported or reading fails.
929 pub fn read_file_by_extension<P: AsRef<Path>>(
930 &mut self,
931 path: P,
932 lang: Option<String>,
933 ) -> Result<(), Error> {
934 let format_type = match path.as_ref().extension().and_then(|s| s.to_str()) {
935 Some("xml") => FormatType::AndroidStrings(lang),
936 Some("strings") => FormatType::Strings(lang),
937 Some("xcstrings") => FormatType::Xcstrings,
938 Some("csv") => FormatType::CSV(lang),
939 extension => {
940 return Err(Error::UnsupportedFormat(format!(
941 "Unsupported file extension: {:?}.",
942 extension
943 )));
944 }
945 };
946
947 self.read_file_by_type(path, format_type)?;
948
949 Ok(())
950 }
951
952 /// Writes all managed resources back to their respective files,
953 /// grouped by domain.
954 ///
955 /// # Returns
956 ///
957 /// `Ok(())` if all writes succeed, or an `Error` otherwise.
958 pub fn write_to_file(&self) -> Result<(), Error> {
959 // Group resources by the domain in a HashMap
960 let mut grouped_resources: std::collections::HashMap<String, Vec<Resource>> =
961 std::collections::HashMap::new();
962 for resource in &*self.resources {
963 let domain = resource.metadata.domain.clone();
964 grouped_resources
965 .entry(domain)
966 .or_default()
967 .push(resource.clone());
968 }
969
970 // Iterate the map and write each resource to its respective file
971 for (domain, resources) in grouped_resources {
972 write_resources_to_file(&resources, &domain)?;
973 }
974
975 Ok(())
976 }
977
978 /// Caches the current resources to a JSON file.
979 ///
980 /// # Parameters
981 /// - `path`: Destination file path for the cache.
982 ///
983 /// # Returns
984 ///
985 /// `Ok(())` if caching succeeds, or an `Error` if file I/O or serialization fails.
986 pub fn cache_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
987 let path = path.as_ref();
988 if let Some(parent) = path.parent() {
989 std::fs::create_dir_all(parent).map_err(Error::Io)?;
990 }
991 let mut writer = std::fs::File::create(path).map_err(Error::Io)?;
992 serde_json::to_writer(&mut writer, &*self.resources).map_err(Error::Parse)?;
993 Ok(())
994 }
995
996 /// Loads resources from a JSON cache file.
997 ///
998 /// # Parameters
999 /// - `path`: Path to the JSON file containing cached resources.
1000 ///
1001 /// # Returns
1002 ///
1003 /// `Ok(Codec)` with loaded resources, or an `Error` if loading or deserialization fails.
1004 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
1005 let mut reader = std::fs::File::open(path).map_err(Error::Io)?;
1006 let resources: Vec<Resource> =
1007 serde_json::from_reader(&mut reader).map_err(Error::Parse)?;
1008 Ok(Codec { resources })
1009 }
1010}
1011
1012/// Attempts to infer the language from the file path based on format conventions.
1013/// For Apple: looks for "{lang}.lproj"; for Android: "values-{lang}".
1014///
1015/// # Parameters
1016/// - `path`: The file path to analyze.
1017/// - `format_type`: The format type to consider for language inference.
1018///
1019/// # Returns
1020///
1021/// `Ok(Some(language_code))` if a language could be inferred,
1022/// `Ok(None)` if no language is applicable for the format,
1023/// or an `Error` if inference fails.
1024pub fn infer_language_from_path<P: AsRef<Path>>(
1025 path: &P,
1026 format_type: &FormatType,
1027) -> Result<Option<String>, Error> {
1028 match &format_type {
1029 FormatType::AndroidStrings(lang) | FormatType::Strings(lang) | FormatType::CSV(lang) => {
1030 let processed_lang = if let Some(lang) = lang {
1031 lang.clone()
1032 } else {
1033 path.as_ref()
1034 .components()
1035 .rev()
1036 .find_map(|c| {
1037 let component = c.as_os_str().to_str()?;
1038 if component.ends_with(".lproj") {
1039 Some(component.trim_end_matches(".lproj").to_string())
1040 } else if component.starts_with("values-") {
1041 Some(component.trim_start_matches("values-").to_string())
1042 } else {
1043 None
1044 }
1045 })
1046 .ok_or(Error::UnknownFormat(
1047 "Failed to infer language from path, please provide a language code manually."
1048 .to_string(),
1049 ))?
1050 };
1051
1052 Ok(Some(processed_lang))
1053 }
1054 _ => Ok(None),
1055 }
1056}
1057
1058/// Writes one or more resources to a file based on their format metadata.
1059/// Supports formats with single or multiple resources per file.
1060///
1061/// # Parameters
1062/// - `resources`: Slice of resources to write.
1063/// - `file_path`: Destination file path.
1064///
1065/// # Returns
1066///
1067/// `Ok(())` if writing succeeds, or an `Error` if the format is unsupported or writing fails.
1068fn write_resources_to_file(resources: &[Resource], file_path: &String) -> Result<(), Error> {
1069 let path = Path::new(&file_path);
1070
1071 if let Some(first) = resources.first() {
1072 match first.metadata.custom.get("format").map(String::as_str) {
1073 Some("AndroidStrings") => AndroidStringsFormat::from(first.clone()).write_to(path)?,
1074 Some("Strings") => StringsFormat::try_from(first.clone())?.write_to(path)?,
1075 Some("Xcstrings") => XcstringsFormat::try_from(resources.to_vec())?.write_to(path)?,
1076 Some("CSV") => Vec::<CSVRecord>::try_from(first.clone())?.write_to(path)?,
1077 _ => Err(Error::UnsupportedFormat(format!(
1078 "Unsupported format: {:?}",
1079 first.metadata.custom.get("format")
1080 )))?,
1081 }
1082 }
1083
1084 Ok(())
1085}
1086
1087/// Convert a localization file from one format to another.
1088///
1089/// # Arguments
1090///
1091/// * `input` - The input file path.
1092/// * `input_format` - The format of the input file.
1093/// * `output` - The output file path.
1094/// * `output_format` - The format of the output file.
1095///
1096/// # Errors
1097///
1098/// Returns an `Error` if reading, parsing, converting, or writing fails.
1099///
1100/// # Example
1101///
1102/// ```rust,no_run
1103/// use langcodec::{convert, formats::FormatType};
1104/// convert(
1105/// "Localizable.strings",
1106/// FormatType::Strings(None),
1107/// "strings.xml",
1108/// FormatType::AndroidStrings(None),
1109/// )?;
1110/// # Ok::<(), langcodec::Error>(())
1111/// ```
1112pub fn convert<P: AsRef<Path>>(
1113 input: P,
1114 input_format: FormatType,
1115 output: P,
1116 output_format: FormatType,
1117) -> Result<(), Error> {
1118 use crate::formats::{AndroidStringsFormat, StringsFormat, XcstringsFormat};
1119 use crate::traits::Parser;
1120
1121 // Propagate language code from input to output format if not specified
1122 let output_format = if let Some(lang) = input_format.language() {
1123 output_format.with_language(Some(lang.clone()))
1124 } else {
1125 output_format
1126 };
1127
1128 if !input_format.matches_language_of(&output_format) {
1129 return Err(Error::InvalidResource(
1130 "Input and output formats must match in language.".to_string(),
1131 ));
1132 }
1133
1134 // Read input as resources
1135 let resources = match input_format {
1136 FormatType::AndroidStrings(_) => vec![AndroidStringsFormat::read_from(&input)?.into()],
1137 FormatType::Strings(_) => vec![StringsFormat::read_from(&input)?.into()],
1138 FormatType::Xcstrings => {
1139 Vec::<crate::types::Resource>::try_from(XcstringsFormat::read_from(&input)?)?
1140 }
1141 FormatType::CSV(_) => vec![Vec::<CSVRecord>::read_from(&input)?.into()],
1142 };
1143
1144 // Helper to extract resource by language if present, or first one
1145 let pick_resource = |lang: Option<String>| -> Option<crate::types::Resource> {
1146 match lang {
1147 Some(l) => resources.iter().find(|r| r.metadata.language == l).cloned(),
1148 None => resources.first().cloned(),
1149 }
1150 };
1151
1152 match output_format {
1153 FormatType::AndroidStrings(lang) => {
1154 let resource = pick_resource(lang);
1155 if let Some(res) = resource {
1156 AndroidStringsFormat::from(res).write_to(&output)
1157 } else {
1158 Err(Error::InvalidResource(
1159 "No matching resource for output language.".to_string(),
1160 ))
1161 }
1162 }
1163 FormatType::Strings(lang) => {
1164 let resource = pick_resource(lang);
1165 if let Some(res) = resource {
1166 StringsFormat::try_from(res)?.write_to(&output)
1167 } else {
1168 Err(Error::InvalidResource(
1169 "No matching resource for output language.".to_string(),
1170 ))
1171 }
1172 }
1173 FormatType::Xcstrings => XcstringsFormat::try_from(resources)?.write_to(&output),
1174 FormatType::CSV(lang) => {
1175 let resource = pick_resource(lang);
1176 if let Some(res) = resource {
1177 Vec::<CSVRecord>::try_from(res)?.write_to(&output)
1178 } else {
1179 Err(Error::InvalidResource(
1180 "No matching resource for output language.".to_string(),
1181 ))
1182 }
1183 }
1184 }
1185}
1186
1187/// Infers a [`FormatType`] from a file path's extension.
1188///
1189/// Returns `Some(FormatType)` if the extension matches a known format, otherwise `None`.
1190///
1191/// # Example
1192/// ```rust
1193/// use langcodec::formats::FormatType;
1194/// use langcodec::codec::infer_format_from_extension;
1195/// assert_eq!(
1196/// infer_format_from_extension("foo.strings"),
1197/// Some(FormatType::Strings(None))
1198/// );
1199/// assert_eq!(
1200/// infer_format_from_extension("foo.xml"),
1201/// Some(FormatType::AndroidStrings(None))
1202/// );
1203/// assert_eq!(
1204/// infer_format_from_extension("foo.xcstrings"),
1205/// Some(FormatType::Xcstrings)
1206/// );
1207/// assert_eq!(
1208/// infer_format_from_extension("foo.txt"),
1209/// None
1210/// );
1211/// ```
1212pub fn infer_format_from_extension<P: AsRef<Path>>(path: P) -> Option<FormatType> {
1213 match path.as_ref().extension().and_then(|s| s.to_str()) {
1214 Some("xml") => Some(FormatType::AndroidStrings(None)),
1215 Some("strings") => Some(FormatType::Strings(None)),
1216 Some("xcstrings") => Some(FormatType::Xcstrings),
1217 Some("csv") => Some(FormatType::CSV(None)),
1218 _ => None,
1219 }
1220}
1221
1222/// Infers the localization file format and language code from a path.
1223///
1224/// - For Apple `.strings`: extracts language from `??.lproj/` (e.g. `en.lproj/Localizable.strings`)
1225/// - For Android `strings.xml`: extracts language from `values-??/` (e.g. `values-es/strings.xml`)
1226/// - For `.xcstrings`: returns format without language info (contained in file)
1227///
1228/// # Examples
1229/// ```rust
1230/// use langcodec::formats::FormatType;
1231/// use langcodec::codec::infer_format_from_path;
1232/// assert_eq!(
1233/// infer_format_from_path("ar.lproj/Localizable.strings"),
1234/// Some(FormatType::Strings(Some("ar".to_string())))
1235/// );
1236/// assert_eq!(
1237/// infer_format_from_path("en.lproj/Localizable.strings"),
1238/// Some(FormatType::Strings(Some("en".to_string())))
1239/// );
1240/// assert_eq!(
1241/// infer_format_from_path("Base.lproj/Localizable.strings"),
1242/// Some(FormatType::Strings(Some("Base".to_string())))
1243/// );
1244/// assert_eq!(
1245/// infer_format_from_path("values-es/strings.xml"),
1246/// Some(FormatType::AndroidStrings(Some("es".to_string())))
1247/// );
1248/// assert_eq!(
1249/// infer_format_from_path("values/strings.xml"),
1250/// Some(FormatType::AndroidStrings(None))
1251/// );
1252/// assert_eq!(
1253/// infer_format_from_path("Localizable.xcstrings"),
1254/// Some(FormatType::Xcstrings)
1255/// );
1256/// ```
1257pub fn infer_format_from_path<P: AsRef<Path>>(path: P) -> Option<FormatType> {
1258 match infer_format_from_extension(&path) {
1259 Some(format) => match format {
1260 FormatType::Xcstrings => Some(format),
1261 FormatType::AndroidStrings(_) | FormatType::Strings(_) | FormatType::CSV(_) => {
1262 let lang = infer_language_from_path(&path, &format).ok().flatten();
1263 Some(format.with_language(lang))
1264 }
1265 },
1266 None => None,
1267 }
1268}
1269
1270/// Convert a localization file from one format to another, inferring formats from file extensions.
1271///
1272/// This function attempts to infer the input and output formats from their file extensions.
1273/// Returns an error if either format cannot be inferred.
1274///
1275/// # Arguments
1276///
1277/// * `input` - The input file path.
1278/// * `output` - The output file path.
1279///
1280/// # Errors
1281///
1282/// Returns an `Error` if the format cannot be inferred, or if conversion fails.
1283///
1284/// # Example
1285///
1286/// ```rust,no_run
1287/// use langcodec::convert_auto;
1288/// convert_auto("Localizable.strings", "strings.xml")?;
1289/// # Ok::<(), langcodec::Error>(())
1290/// ```
1291pub fn convert_auto<P: AsRef<Path>>(input: P, output: P) -> Result<(), Error> {
1292 let input_format = infer_format_from_path(&input).ok_or_else(|| {
1293 Error::UnknownFormat(format!(
1294 "Cannot infer input format from extension: {:?}",
1295 input.as_ref().extension()
1296 ))
1297 })?;
1298 let output_format = infer_format_from_path(&output).ok_or_else(|| {
1299 Error::UnknownFormat(format!(
1300 "Cannot infer output format from extension: {:?}",
1301 output.as_ref().extension()
1302 ))
1303 })?;
1304 convert(input, input_format, output, output_format)
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309 use super::*;
1310 use crate::types::{Entry, EntryStatus, Metadata, Translation};
1311
1312 #[test]
1313 fn test_builder_pattern() {
1314 // Test creating an empty codec
1315 let codec = Codec::builder().build();
1316 assert_eq!(codec.resources.len(), 0);
1317
1318 // Test adding resources directly
1319 let resource1 = Resource {
1320 metadata: Metadata {
1321 language: "en".to_string(),
1322 domain: "test".to_string(),
1323 custom: std::collections::HashMap::new(),
1324 },
1325 entries: vec![Entry {
1326 id: "hello".to_string(),
1327 value: Translation::Singular("Hello".to_string()),
1328 comment: None,
1329 status: EntryStatus::Translated,
1330 custom: std::collections::HashMap::new(),
1331 }],
1332 };
1333
1334 let resource2 = Resource {
1335 metadata: Metadata {
1336 language: "fr".to_string(),
1337 domain: "test".to_string(),
1338 custom: std::collections::HashMap::new(),
1339 },
1340 entries: vec![Entry {
1341 id: "hello".to_string(),
1342 value: Translation::Singular("Bonjour".to_string()),
1343 comment: None,
1344 status: EntryStatus::Translated,
1345 custom: std::collections::HashMap::new(),
1346 }],
1347 };
1348
1349 let codec = Codec::builder()
1350 .add_resource(resource1.clone())
1351 .add_resource(resource2.clone())
1352 .build();
1353
1354 assert_eq!(codec.resources.len(), 2);
1355 assert_eq!(codec.resources[0].metadata.language, "en");
1356 assert_eq!(codec.resources[1].metadata.language, "fr");
1357 }
1358
1359 #[test]
1360 fn test_builder_validation() {
1361 // Test validation with empty language
1362 let resource_without_language = Resource {
1363 metadata: Metadata {
1364 language: "".to_string(),
1365 domain: "test".to_string(),
1366 custom: std::collections::HashMap::new(),
1367 },
1368 entries: vec![],
1369 };
1370
1371 let result = Codec::builder()
1372 .add_resource(resource_without_language)
1373 .build_and_validate();
1374
1375 assert!(result.is_err());
1376 assert!(matches!(result.unwrap_err(), Error::Validation(_)));
1377
1378 // Test validation with duplicate languages
1379 let resource1 = Resource {
1380 metadata: Metadata {
1381 language: "en".to_string(),
1382 domain: "test".to_string(),
1383 custom: std::collections::HashMap::new(),
1384 },
1385 entries: vec![],
1386 };
1387
1388 let resource2 = Resource {
1389 metadata: Metadata {
1390 language: "en".to_string(), // Duplicate language
1391 domain: "test".to_string(),
1392 custom: std::collections::HashMap::new(),
1393 },
1394 entries: vec![],
1395 };
1396
1397 let result = Codec::builder()
1398 .add_resource(resource1)
1399 .add_resource(resource2)
1400 .build_and_validate();
1401
1402 assert!(result.is_err());
1403 assert!(matches!(result.unwrap_err(), Error::Validation(_)));
1404 }
1405
1406 #[test]
1407 fn test_builder_add_resources() {
1408 let resources = vec![
1409 Resource {
1410 metadata: Metadata {
1411 language: "en".to_string(),
1412 domain: "test".to_string(),
1413 custom: std::collections::HashMap::new(),
1414 },
1415 entries: vec![],
1416 },
1417 Resource {
1418 metadata: Metadata {
1419 language: "fr".to_string(),
1420 domain: "test".to_string(),
1421 custom: std::collections::HashMap::new(),
1422 },
1423 entries: vec![],
1424 },
1425 ];
1426
1427 let codec = Codec::builder().add_resources(resources).build();
1428 assert_eq!(codec.resources.len(), 2);
1429 assert_eq!(codec.resources[0].metadata.language, "en");
1430 assert_eq!(codec.resources[1].metadata.language, "fr");
1431 }
1432
1433 #[test]
1434 fn test_modification_methods() {
1435 use crate::types::{EntryStatus, Translation};
1436
1437 // Create a codec with some test data
1438 let mut codec = Codec::new();
1439
1440 // Add resources
1441 let resource1 = Resource {
1442 metadata: Metadata {
1443 language: "en".to_string(),
1444 domain: "test".to_string(),
1445 custom: std::collections::HashMap::new(),
1446 },
1447 entries: vec![Entry {
1448 id: "welcome".to_string(),
1449 value: Translation::Singular("Hello".to_string()),
1450 comment: None,
1451 status: EntryStatus::Translated,
1452 custom: std::collections::HashMap::new(),
1453 }],
1454 };
1455
1456 let resource2 = Resource {
1457 metadata: Metadata {
1458 language: "fr".to_string(),
1459 domain: "test".to_string(),
1460 custom: std::collections::HashMap::new(),
1461 },
1462 entries: vec![Entry {
1463 id: "welcome".to_string(),
1464 value: Translation::Singular("Bonjour".to_string()),
1465 comment: None,
1466 status: EntryStatus::Translated,
1467 custom: std::collections::HashMap::new(),
1468 }],
1469 };
1470
1471 codec.add_resource(resource1);
1472 codec.add_resource(resource2);
1473
1474 // Test find_entries
1475 let entries = codec.find_entries("welcome");
1476 assert_eq!(entries.len(), 2);
1477 assert_eq!(entries[0].0.metadata.language, "en");
1478 assert_eq!(entries[1].0.metadata.language, "fr");
1479
1480 // Test find_entry
1481 let entry = codec.find_entry("welcome", "en");
1482 assert!(entry.is_some());
1483 assert_eq!(entry.unwrap().id, "welcome");
1484
1485 // Test find_entry_mut and update
1486 if let Some(entry) = codec.find_entry_mut("welcome", "en") {
1487 entry.value = Translation::Singular("Hello, World!".to_string());
1488 entry.status = EntryStatus::NeedsReview;
1489 }
1490
1491 // Verify the update
1492 let updated_entry = codec.find_entry("welcome", "en").unwrap();
1493 assert_eq!(updated_entry.value.to_string(), "Hello, World!");
1494 assert_eq!(updated_entry.status, EntryStatus::NeedsReview);
1495
1496 // Test update_translation
1497 codec
1498 .update_translation(
1499 "welcome",
1500 "fr",
1501 Translation::Singular("Bonjour, le monde!".to_string()),
1502 Some(EntryStatus::NeedsReview),
1503 )
1504 .unwrap();
1505
1506 // Test add_entry
1507 codec
1508 .add_entry(
1509 "new_key",
1510 "en",
1511 Translation::Singular("New message".to_string()),
1512 Some("A new message".to_string()),
1513 Some(EntryStatus::New),
1514 )
1515 .unwrap();
1516
1517 assert!(codec.has_entry("new_key", "en"));
1518 assert_eq!(codec.entry_count("en"), 2);
1519
1520 // Test remove_entry
1521 codec.remove_entry("new_key", "en").unwrap();
1522 assert!(!codec.has_entry("new_key", "en"));
1523 assert_eq!(codec.entry_count("en"), 1);
1524
1525 // Test copy_entry
1526 codec.copy_entry("welcome", "en", "fr", true).unwrap();
1527 let copied_entry = codec.find_entry("welcome", "fr").unwrap();
1528 assert_eq!(copied_entry.status, EntryStatus::New);
1529
1530 // Test languages
1531 let languages: Vec<_> = codec.languages().collect();
1532 assert_eq!(languages.len(), 2);
1533 assert!(languages.contains(&"en"));
1534 assert!(languages.contains(&"fr"));
1535
1536 // Test all_keys
1537 let keys: Vec<_> = codec.all_keys().collect();
1538 assert_eq!(keys.len(), 1);
1539 assert!(keys.contains(&"welcome"));
1540 }
1541
1542 #[test]
1543 fn test_validation() {
1544 let mut codec = Codec::new();
1545
1546 // Test validation with empty language
1547 let resource_without_language = Resource {
1548 metadata: Metadata {
1549 language: "".to_string(),
1550 domain: "test".to_string(),
1551 custom: std::collections::HashMap::new(),
1552 },
1553 entries: vec![],
1554 };
1555
1556 codec.add_resource(resource_without_language);
1557 assert!(codec.validate().is_err());
1558
1559 // Test validation with duplicate languages
1560 let mut codec = Codec::new();
1561 let resource1 = Resource {
1562 metadata: Metadata {
1563 language: "en".to_string(),
1564 domain: "test".to_string(),
1565 custom: std::collections::HashMap::new(),
1566 },
1567 entries: vec![],
1568 };
1569
1570 let resource2 = Resource {
1571 metadata: Metadata {
1572 language: "en".to_string(), // Duplicate language
1573 domain: "test".to_string(),
1574 custom: std::collections::HashMap::new(),
1575 },
1576 entries: vec![],
1577 };
1578
1579 codec.add_resource(resource1);
1580 codec.add_resource(resource2);
1581 assert!(codec.validate().is_err());
1582
1583 // Test validation with missing translations
1584 let mut codec = Codec::new();
1585 let resource1 = Resource {
1586 metadata: Metadata {
1587 language: "en".to_string(),
1588 domain: "test".to_string(),
1589 custom: std::collections::HashMap::new(),
1590 },
1591 entries: vec![Entry {
1592 id: "welcome".to_string(),
1593 value: Translation::Singular("Hello".to_string()),
1594 comment: None,
1595 status: EntryStatus::Translated,
1596 custom: std::collections::HashMap::new(),
1597 }],
1598 };
1599
1600 let resource2 = Resource {
1601 metadata: Metadata {
1602 language: "fr".to_string(),
1603 domain: "test".to_string(),
1604 custom: std::collections::HashMap::new(),
1605 },
1606 entries: vec![], // Missing welcome entry
1607 };
1608
1609 codec.add_resource(resource1);
1610 codec.add_resource(resource2);
1611 assert!(codec.validate().is_err());
1612 }
1613}