cs/parse/
key_extractor.rs

1// src/parse/key_extractor.rs
2
3use crate::error::Result;
4use std::path::Path;
5use walkdir::WalkDir;
6
7use super::json_parser::JsonParser;
8use super::translation::TranslationEntry;
9use super::yaml_parser::YamlParser;
10
11/// `KeyExtractor` provides functionality to search translation entries across
12/// multiple YAML translation files, returning the full dot‑notation key path,
13/// associated file path and line number for each match.
14pub struct KeyExtractor;
15
16impl Default for KeyExtractor {
17    fn default() -> Self {
18        Self::new()
19    }
20}
21
22impl KeyExtractor {
23    /// Create a new `KeyExtractor`.
24    pub fn new() -> Self {
25        Self
26    }
27
28    /// Recursively walk `base_dir` for `*.yml` (or `*.yaml`) files, parse each,
29    /// and return entries whose **value** contains `query`.
30    ///
31    /// Matching is case‑insensitive by default.
32    pub fn extract(&self, base_dir: &Path, query: &str) -> Result<Vec<TranslationEntry>> {
33        let mut matches = Vec::new();
34        let lowered = query.to_lowercase();
35
36        for entry in WalkDir::new(base_dir)
37            .into_iter()
38            .filter_map(|e| e.ok())
39            .filter(|e| e.file_type().is_file())
40        {
41            let path = entry.path();
42            if let Some(ext) = path.extension() {
43                let ext_str = ext.to_string_lossy();
44                if ext_str == "yml" || ext_str == "yaml" {
45                    let entries = YamlParser::parse_file(path)?;
46                    for e in entries {
47                        if e.value.to_lowercase().contains(&lowered) {
48                            matches.push(e);
49                        }
50                    }
51                } else if ext_str == "json" {
52                    let entries = JsonParser::parse_file(path)?;
53                    for e in entries {
54                        if e.value.to_lowercase().contains(&lowered) {
55                            matches.push(e);
56                        }
57                    }
58                }
59            }
60        }
61        Ok(matches)
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use std::fs;
69
70    use tempfile::tempdir;
71
72    #[test]
73    fn test_key_extractor_simple() -> Result<()> {
74        let dir = tempdir()?;
75        let en_path = dir.path().join("en.yml");
76        let fr_path = dir.path().join("fr.yml");
77
78        // Write simple yaml files with proper format
79        fs::write(
80            &en_path,
81            "greeting:\n  hello: \"Hello World\"\n  goodbye: \"Goodbye\"",
82        )?;
83        fs::write(
84            &fr_path,
85            "greeting:\n  hello: \"Bonjour World\"\n  goodbye: \"Au revoir\"",
86        )?;
87
88        let extractor = KeyExtractor::new();
89        let results = extractor.extract(dir.path(), "world")?;
90
91        // Should find two entries (en and fr)
92        assert_eq!(results.len(), 2);
93        let keys: Vec<_> = results.iter().map(|e| e.key.clone()).collect();
94        assert!(keys.contains(&"greeting.hello".to_string()));
95        Ok(())
96    }
97
98    #[test]
99    fn test_key_extractor_case_insensitive() -> Result<()> {
100        let dir = tempdir()?;
101        let yaml_path = dir.path().join("test.yml");
102
103        fs::write(
104            &yaml_path,
105            "app:\n  title: \"My Application\"\n  description: \"A great APP for everyone\"",
106        )?;
107
108        let extractor = KeyExtractor::new();
109
110        // Test case insensitive search
111        let results = extractor.extract(dir.path(), "APP")?;
112        assert_eq!(results.len(), 2); // Should match both "Application" and "APP"
113
114        let values: Vec<_> = results.iter().map(|e| e.value.clone()).collect();
115        assert!(values.contains(&"My Application".to_string()));
116        assert!(values.contains(&"A great APP for everyone".to_string()));
117
118        Ok(())
119    }
120
121    #[test]
122    fn test_key_extractor_multiple_files() -> Result<()> {
123        let dir = tempdir()?;
124
125        // Create multiple language files
126        let en_path = dir.path().join("en.yml");
127        let fr_path = dir.path().join("fr.yml");
128        let de_path = dir.path().join("de.yml");
129
130        fs::write(&en_path, "common:\n  action: \"Save Data\"")?;
131        fs::write(&fr_path, "common:\n  action: \"Sauvegarder Data\"")?;
132        fs::write(&de_path, "common:\n  action: \"Speichern Data\"")?;
133
134        let extractor = KeyExtractor::new();
135        let results = extractor.extract(dir.path(), "data")?;
136
137        // Should find all three files (case-insensitive)
138        assert_eq!(results.len(), 3);
139
140        let files: Vec<_> = results
141            .iter()
142            .map(|e| e.file.file_name().unwrap().to_string_lossy().to_string())
143            .collect();
144        assert!(files.contains(&"en.yml".to_string()));
145        assert!(files.contains(&"fr.yml".to_string()));
146        assert!(files.contains(&"de.yml".to_string()));
147
148        Ok(())
149    }
150
151    #[test]
152    fn test_key_extractor_deep_nested() -> Result<()> {
153        let dir = tempdir()?;
154        let yaml_path = dir.path().join("nested.yml");
155
156        fs::write(
157            &yaml_path,
158            "level1:\n  level2:\n    level3:\n      deep_key: \"Deep nested value\"\n      another: \"test value\"",
159        )?;
160
161        let extractor = KeyExtractor::new();
162        let results = extractor.extract(dir.path(), "deep")?;
163
164        assert_eq!(results.len(), 1);
165        assert_eq!(results[0].key, "level1.level2.level3.deep_key");
166        assert_eq!(results[0].value, "Deep nested value");
167
168        Ok(())
169    }
170
171    #[test]
172    fn test_key_extractor_no_matches() -> Result<()> {
173        let dir = tempdir()?;
174        let yaml_path = dir.path().join("test.yml");
175
176        fs::write(
177            &yaml_path,
178            "greeting:\n  hello: \"Hello\"\n  goodbye: \"Goodbye\"",
179        )?;
180
181        let extractor = KeyExtractor::new();
182        let results = extractor.extract(dir.path(), "nonexistent")?;
183
184        assert_eq!(results.len(), 0);
185
186        Ok(())
187    }
188
189    #[test]
190    fn test_key_extractor_supports_json_and_yaml() -> Result<()> {
191        let dir = tempdir()?;
192        let yaml_path = dir.path().join("test.yml");
193        let txt_path = dir.path().join("test.txt");
194        let json_path = dir.path().join("test.json");
195
196        fs::write(&yaml_path, "key: \"test value\"")?;
197        fs::write(&txt_path, "key: test value")?; // This should be ignored
198        fs::write(&json_path, "{\"key\": \"test value\"}")?; // This should be ignored
199
200        let extractor = KeyExtractor::new();
201        let results = extractor.extract(dir.path(), "test")?;
202
203        // Should find both YAML and JSON files
204        assert_eq!(results.len(), 2);
205        let extensions: Vec<_> = results
206            .iter()
207            .map(|e| e.file.extension().unwrap().to_string_lossy().to_string())
208            .collect();
209        assert!(extensions.contains(&"yml".to_string()));
210        assert!(extensions.contains(&"json".to_string()));
211
212        Ok(())
213    }
214}