packc/i18n_build/
bundle.rs1#![forbid(unsafe_code)]
2use std::fs;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13use serde_json::Value;
14use walkdir::WalkDir;
15
16use super::extract::{ExtractedString, extract_from_value};
17
18#[derive(Debug, Clone)]
24pub struct ExtractConfig {
25 pub cards_dir: PathBuf,
27 pub output: PathBuf,
29 pub prefix: String,
31 pub skip_i18n_patterns: bool,
33}
34
35pub fn extract_from_directory(config: &ExtractConfig) -> Result<Vec<ExtractedString>> {
41 let mut all_strings = Vec::new();
42
43 for entry in WalkDir::new(&config.cards_dir)
44 .into_iter()
45 .filter_map(Result::ok)
46 {
47 if !entry.file_type().is_file() {
48 continue;
49 }
50
51 let path = entry.path();
52 let extension = path.extension().and_then(|ext| ext.to_str());
53 if extension.is_none_or(|ext| !ext.eq_ignore_ascii_case("json")) {
54 continue;
55 }
56
57 let contents = fs::read_to_string(path)
58 .with_context(|| format!("failed to read {}", path.display()))?;
59
60 let value: Value = serde_json::from_str(&contents)
61 .with_context(|| format!("invalid JSON in {}", path.display()))?;
62
63 if !is_adaptive_card(&value) {
64 continue;
65 }
66
67 let card_id = determine_card_id(path, &value, &config.cards_dir)?;
68 let full_prefix = if config.prefix.is_empty() {
69 card_id.clone()
70 } else {
71 format!("{}.{}", config.prefix, card_id)
72 };
73
74 let strings = extract_from_value(&value, &full_prefix, "", path, config.skip_i18n_patterns);
75 all_strings.extend(strings);
76 }
77
78 Ok(all_strings)
79}
80
81pub fn to_json_bundle(strings: &[ExtractedString]) -> Value {
83 let mut map = serde_json::Map::new();
84 for s in strings {
85 map.insert(s.key.clone(), Value::String(s.value.clone()));
86 }
87 Value::Object(map)
88}
89
90pub fn write_bundle(strings: &[ExtractedString], output: &Path) -> Result<()> {
92 let json = to_json_bundle(strings);
93 let contents = serde_json::to_string_pretty(&json)?;
94
95 if let Some(parent) = output.parent() {
96 fs::create_dir_all(parent)?;
97 }
98
99 fs::write(output, contents).with_context(|| format!("failed to write {}", output.display()))?;
100
101 Ok(())
102}
103
104fn is_adaptive_card(value: &Value) -> bool {
110 let Some(obj) = value.as_object() else {
111 return false;
112 };
113 let card_type = obj.get("type").and_then(|v| v.as_str());
114 card_type == Some("AdaptiveCard") || obj.contains_key("body") || obj.contains_key("actions")
115}
116
117fn determine_card_id(path: &Path, value: &Value, cards_dir: &Path) -> Result<String> {
119 if let Some(card_id) = value
121 .get("greentic")
122 .and_then(|v| v.as_object())
123 .and_then(|obj| obj.get("cardId"))
124 .and_then(|v| v.as_str())
125 {
126 return Ok(sanitize_key_part(card_id));
127 }
128
129 let rel_path = path.strip_prefix(cards_dir).unwrap_or(path);
131 let stem = rel_path
132 .file_stem()
133 .and_then(|s| s.to_str())
134 .ok_or_else(|| anyhow::anyhow!("cannot determine card ID for {}", path.display()))?;
135
136 Ok(sanitize_key_part(stem))
137}
138
139fn sanitize_key_part(s: &str) -> String {
141 s.chars()
142 .map(|c| {
143 if c.is_alphanumeric() || c == '_' {
144 c
145 } else {
146 '_'
147 }
148 })
149 .collect::<String>()
150 .to_lowercase()
151}
152
153#[cfg(test)]
158mod tests {
159 use std::path::PathBuf;
160
161 use serde_json::json;
162
163 use super::*;
164
165 #[test]
166 fn test_sanitize_key_part() {
167 assert_eq!(sanitize_key_part("my-card"), "my_card");
168 assert_eq!(sanitize_key_part("My Card"), "my_card");
169 assert_eq!(sanitize_key_part("card_123"), "card_123");
170 assert_eq!(sanitize_key_part("Card.Name"), "card_name");
171 }
172
173 #[test]
174 fn test_determine_card_id_from_metadata() {
175 let card = json!({
176 "type": "AdaptiveCard",
177 "greentic": { "cardId": "my-custom-id" },
178 "body": []
179 });
180 let id = determine_card_id(Path::new("some-filename.json"), &card, Path::new("")).unwrap();
181 assert_eq!(id, "my_custom_id");
182 }
183
184 #[test]
185 fn test_determine_card_id_fallback_to_filename() {
186 let card = json!({ "type": "AdaptiveCard", "body": [] });
187 let id =
188 determine_card_id(Path::new("cards/my-card.json"), &card, Path::new("cards")).unwrap();
189 assert_eq!(id, "my_card");
190 }
191
192 #[test]
193 fn test_to_json_bundle() {
194 let strings = vec![
195 ExtractedString {
196 key: "card.title".to_string(),
197 value: "Hello".to_string(),
198 source_file: PathBuf::from("test.json"),
199 json_path: "body[0].text".to_string(),
200 },
201 ExtractedString {
202 key: "card.button".to_string(),
203 value: "Click me".to_string(),
204 source_file: PathBuf::from("test.json"),
205 json_path: "actions[0].title".to_string(),
206 },
207 ];
208
209 let bundle = to_json_bundle(&strings);
210 assert_eq!(bundle["card.title"], "Hello");
211 assert_eq!(bundle["card.button"], "Click me");
212 }
213
214 #[test]
215 fn test_write_bundle_creates_parent_dirs() {
216 let tmp = tempfile::TempDir::new().unwrap();
217 let output = tmp.path().join("deep/nested/dir/en.json");
218 let strings = vec![ExtractedString {
219 key: "k".to_string(),
220 value: "v".to_string(),
221 source_file: PathBuf::from("test.json"),
222 json_path: "text".to_string(),
223 }];
224 write_bundle(&strings, &output).unwrap();
225 assert!(output.is_file());
226 }
227}