Skip to main content

packc/i18n_build/
bundle.rs

1#![forbid(unsafe_code)]
2//! Directory scanning, bundle generation, and card ID resolution for i18n extraction.
3//!
4//! This module handles the directory-level operations: scanning for Adaptive Cards,
5//! converting extracted strings to JSON bundles, and writing output files.
6//!
7//! Ported from `greentic-cards2pack/src/i18n_extract/mod.rs`.
8
9use 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// ---------------------------------------------------------------------------
19// Public types
20// ---------------------------------------------------------------------------
21
22/// Configuration for i18n extraction.
23#[derive(Debug, Clone)]
24pub struct ExtractConfig {
25    /// Directory containing Adaptive Card JSON files.
26    pub cards_dir: PathBuf,
27    /// Output JSON file path.
28    pub output: PathBuf,
29    /// Key prefix (default: "card").
30    pub prefix: String,
31    /// Skip strings that already contain $t() patterns.
32    pub skip_i18n_patterns: bool,
33}
34
35// ---------------------------------------------------------------------------
36// Directory-level entry points
37// ---------------------------------------------------------------------------
38
39/// Extract translatable strings from all cards in a directory.
40pub 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
81/// Convert extracted strings to a JSON bundle format.
82pub 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
90/// Write extracted strings to a JSON file.
91pub 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
104// ---------------------------------------------------------------------------
105// Card-level helpers (private)
106// ---------------------------------------------------------------------------
107
108/// Check if a JSON value represents an Adaptive Card.
109fn 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
117/// Determine the card ID from the file path or card metadata.
118fn determine_card_id(path: &Path, value: &Value, cards_dir: &Path) -> Result<String> {
119    // Try greentic.cardId first
120    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    // Fall back to file stem
130    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
139/// Sanitize a string for use as a key part.
140fn 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// ---------------------------------------------------------------------------
154// Tests — from mod.rs
155// ---------------------------------------------------------------------------
156
157#[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}