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