Skip to main content

xcstrings_mcp/service/
creator.rs

1use indexmap::IndexMap;
2
3use crate::error::XcStringsError;
4use crate::model::xcstrings::{
5    ExtractionState, Localization, StringEntry, StringUnit, TranslationState, XcStringsFile,
6};
7
8/// Create an empty XcStringsFile with the given source language.
9pub fn create_empty_file(source_language: &str) -> Result<XcStringsFile, XcStringsError> {
10    if source_language.is_empty() {
11        return Err(XcStringsError::InvalidFormat(
12            "source_language is empty".into(),
13        ));
14    }
15    Ok(XcStringsFile {
16        source_language: source_language.to_string(),
17        strings: IndexMap::new(),
18        version: "1.0".to_string(),
19    })
20}
21
22/// Request to add a single key to an XcStringsFile.
23pub struct AddKeyRequest {
24    pub key: String,
25    pub source_text: String,
26    pub comment: Option<String>,
27}
28
29/// Result of adding keys to a file.
30pub struct AddKeysResult {
31    pub added: usize,
32    pub skipped: Vec<String>,
33}
34
35/// Add keys to an XcStringsFile. Skips duplicates.
36pub fn add_keys(file: &mut XcStringsFile, keys: &[AddKeyRequest]) -> AddKeysResult {
37    let source_language = file.source_language.clone();
38    let mut added = 0;
39    let mut skipped = Vec::new();
40
41    for req in keys {
42        if file.strings.contains_key(&req.key) {
43            skipped.push(req.key.clone());
44            continue;
45        }
46
47        let mut localizations = IndexMap::new();
48        localizations.insert(
49            source_language.clone(),
50            Localization {
51                string_unit: Some(StringUnit {
52                    state: TranslationState::Translated,
53                    value: req.source_text.clone(),
54                }),
55                variations: None,
56                substitutions: None,
57            },
58        );
59
60        let entry = StringEntry {
61            extraction_state: Some(ExtractionState::Manual),
62            should_translate: true,
63            comment: req.comment.clone(),
64            localizations: Some(localizations),
65        };
66
67        file.strings.insert(req.key.clone(), entry);
68        added += 1;
69    }
70
71    AddKeysResult { added, skipped }
72}
73
74/// Update comments on existing keys. Returns count of updated keys.
75/// Silently skips non-existent keys.
76pub fn update_comments(file: &mut XcStringsFile, updates: &[(String, String)]) -> usize {
77    let mut count = 0;
78    for (key, comment) in updates {
79        if let Some(entry) = file.strings.get_mut(key) {
80            entry.comment = Some(comment.clone());
81            count += 1;
82        }
83    }
84    count
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn create_empty_file_valid() {
93        let file = create_empty_file("en").unwrap();
94        assert_eq!(file.source_language, "en");
95        assert!(file.strings.is_empty());
96        assert_eq!(file.version, "1.0");
97    }
98
99    #[test]
100    fn create_empty_file_empty_source_language() {
101        let result = create_empty_file("");
102        assert!(matches!(
103            result.unwrap_err(),
104            XcStringsError::InvalidFormat(_)
105        ));
106    }
107
108    #[test]
109    fn add_keys_to_empty_file() {
110        let mut file = create_empty_file("en").unwrap();
111        let keys = vec![AddKeyRequest {
112            key: "greeting".to_string(),
113            source_text: "Hello".to_string(),
114            comment: Some("A greeting".to_string()),
115        }];
116
117        let result = add_keys(&mut file, &keys);
118        assert_eq!(result.added, 1);
119        assert!(result.skipped.is_empty());
120        assert_eq!(file.strings.len(), 1);
121
122        let entry = &file.strings["greeting"];
123        assert_eq!(entry.extraction_state, Some(ExtractionState::Manual));
124        assert!(entry.should_translate);
125        assert_eq!(entry.comment.as_deref(), Some("A greeting"));
126
127        let locs = entry.localizations.as_ref().unwrap();
128        let en = locs.get("en").unwrap().string_unit.as_ref().unwrap();
129        assert_eq!(en.state, TranslationState::Translated);
130        assert_eq!(en.value, "Hello");
131    }
132
133    #[test]
134    fn add_keys_to_existing_file() {
135        let mut file = create_empty_file("en").unwrap();
136        let keys1 = vec![AddKeyRequest {
137            key: "a".to_string(),
138            source_text: "Alpha".to_string(),
139            comment: None,
140        }];
141        add_keys(&mut file, &keys1);
142
143        let keys2 = vec![AddKeyRequest {
144            key: "b".to_string(),
145            source_text: "Beta".to_string(),
146            comment: None,
147        }];
148        let result = add_keys(&mut file, &keys2);
149        assert_eq!(result.added, 1);
150        assert_eq!(file.strings.len(), 2);
151    }
152
153    #[test]
154    fn add_keys_duplicate_skip() {
155        let mut file = create_empty_file("en").unwrap();
156        let keys = vec![
157            AddKeyRequest {
158                key: "dup".to_string(),
159                source_text: "First".to_string(),
160                comment: None,
161            },
162            AddKeyRequest {
163                key: "dup".to_string(),
164                source_text: "Second".to_string(),
165                comment: None,
166            },
167        ];
168
169        let result = add_keys(&mut file, &keys);
170        assert_eq!(result.added, 1);
171        assert_eq!(result.skipped, vec!["dup"]);
172        // First value should be kept
173        let locs = file.strings["dup"].localizations.as_ref().unwrap();
174        let en = locs.get("en").unwrap().string_unit.as_ref().unwrap();
175        assert_eq!(en.value, "First");
176    }
177
178    #[test]
179    fn add_keys_empty_list() {
180        let mut file = create_empty_file("en").unwrap();
181        let result = add_keys(&mut file, &[]);
182        assert_eq!(result.added, 0);
183        assert!(result.skipped.is_empty());
184    }
185
186    #[test]
187    fn add_keys_comment_preserved() {
188        let mut file = create_empty_file("en").unwrap();
189        let keys = vec![AddKeyRequest {
190            key: "k".to_string(),
191            source_text: "val".to_string(),
192            comment: Some("My comment".to_string()),
193        }];
194        add_keys(&mut file, &keys);
195        assert_eq!(file.strings["k"].comment.as_deref(), Some("My comment"));
196    }
197
198    #[test]
199    fn update_comments_updates_existing() {
200        let mut file = create_empty_file("en").unwrap();
201        let keys = vec![
202            AddKeyRequest {
203                key: "a".to_string(),
204                source_text: "Alpha".to_string(),
205                comment: None,
206            },
207            AddKeyRequest {
208                key: "b".to_string(),
209                source_text: "Beta".to_string(),
210                comment: Some("old".to_string()),
211            },
212        ];
213        add_keys(&mut file, &keys);
214
215        let updates = vec![
216            ("a".to_string(), "New comment for A".to_string()),
217            ("b".to_string(), "Updated comment for B".to_string()),
218            ("nonexistent".to_string(), "Should be skipped".to_string()),
219        ];
220        let count = update_comments(&mut file, &updates);
221        assert_eq!(count, 2);
222        assert_eq!(
223            file.strings["a"].comment.as_deref(),
224            Some("New comment for A")
225        );
226        assert_eq!(
227            file.strings["b"].comment.as_deref(),
228            Some("Updated comment for B")
229        );
230    }
231
232    #[test]
233    fn update_comments_nonexistent_key_skipped() {
234        let mut file = create_empty_file("en").unwrap();
235        let updates = vec![("missing".to_string(), "comment".to_string())];
236        let count = update_comments(&mut file, &updates);
237        assert_eq!(count, 0);
238    }
239}