Skip to main content

es_fluent_cli_helpers/
cli.rs

1//! Inventory collection functionality for CLI commands.
2
3use serde::Serialize;
4use std::collections::{HashMap, HashSet};
5
6/// Expected key information from inventory.
7#[derive(Serialize)]
8pub struct ExpectedKey {
9    pub key: String,
10    pub variables: Vec<String>,
11    /// The Rust source file where this key is defined.
12    pub source_file: Option<String>,
13    /// The line number in the Rust source file.
14    pub source_line: Option<u32>,
15}
16
17/// The inventory data output.
18#[derive(Serialize)]
19pub struct InventoryData {
20    pub expected_keys: Vec<ExpectedKey>,
21}
22
23/// Intermediate metadata for a key during collection.
24#[derive(Default)]
25struct KeyMeta {
26    variables: HashSet<String>,
27    source_file: Option<String>,
28    source_line: Option<u32>,
29}
30
31/// Collects inventory data for a crate and writes it to `inventory.json`.
32///
33/// This function is used by the es-fluent CLI to collect expected FTL keys
34/// and their variables from inventory-registered types.
35///
36/// # Arguments
37///
38/// * `crate_name` - The name of the crate to collect inventory for (e.g., "my-crate")
39///
40/// # Panics
41///
42/// Panics if serialization or file writing fails.
43pub fn write_inventory_for_crate(crate_name: &str) {
44    let crate_ident = crate_name.replace('-', "_");
45
46    // Collect all registered type infos for this crate
47    let type_infos: Vec<_> = es_fluent::registry::get_all_ftl_type_infos()
48        .filter(|info| {
49            info.module_path == crate_ident
50                || info.module_path.starts_with(&format!("{}::", crate_ident))
51        })
52        .collect();
53
54    // Build a map of expected keys with their metadata
55    let mut keys_map: HashMap<String, KeyMeta> = HashMap::new();
56    for info in &type_infos {
57        for variant in info.variants {
58            let key = variant.ftl_key.to_string();
59            let vars: HashSet<String> = variant.args.iter().map(|s| s.to_string()).collect();
60            let entry = keys_map.entry(key).or_insert_with(|| KeyMeta {
61                variables: HashSet::new(),
62                source_file: if info.file_path.is_empty() {
63                    None
64                } else {
65                    Some(info.file_path.to_string())
66                },
67                source_line: Some(variant.line),
68            });
69            entry.variables.extend(vars);
70            // Keep the first source location we encounter
71        }
72    }
73
74    // Convert to output format
75    let expected_keys: Vec<ExpectedKey> = keys_map
76        .into_iter()
77        .map(|(key, meta)| ExpectedKey {
78            key,
79            variables: meta.variables.into_iter().collect(),
80            source_file: meta.source_file,
81            source_line: meta.source_line,
82        })
83        .collect();
84
85    let data = InventoryData { expected_keys };
86
87    // Write inventory data to file
88    let json = serde_json::to_string(&data).expect("Failed to serialize inventory data");
89
90    let metadata_dir = es_fluent_derive_core::create_metadata_dir(crate_name)
91        .expect("Failed to create metadata directory");
92    let inventory_path = metadata_dir.join("inventory.json");
93
94    std::fs::write(&inventory_path, json).expect("Failed to write inventory file");
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use es_fluent::registry::{FtlTypeInfo, FtlVariant, NamespaceRule, RegisteredFtlType};
101    use es_fluent_derive_core::meta::TypeKind;
102    use tempfile::tempdir;
103
104    static VARIANTS: &[FtlVariant] = &[
105        FtlVariant {
106            name: "Primary",
107            ftl_key: "my_key",
108            args: &["name", "count"],
109            module_path: "test_crate",
110            line: 42,
111        },
112        FtlVariant {
113            name: "Secondary",
114            ftl_key: "my_key",
115            args: &["extra"],
116            module_path: "test_crate",
117            line: 55,
118        },
119    ];
120
121    static INFO: FtlTypeInfo = FtlTypeInfo {
122        type_kind: TypeKind::Struct,
123        type_name: "InventoryType",
124        variants: VARIANTS,
125        file_path: "src/lib.rs",
126        module_path: "test_crate",
127        namespace: Some(NamespaceRule::Literal("ui")),
128    };
129
130    es_fluent::__inventory::submit! {
131        RegisteredFtlType(&INFO)
132    }
133
134    static VARIANTS_NO_FILE: &[FtlVariant] = &[FtlVariant {
135        name: "NoFilePath",
136        ftl_key: "empty_file_key",
137        args: &[],
138        module_path: "test_crate_empty_file",
139        line: 7,
140    }];
141
142    static INFO_NO_FILE: FtlTypeInfo = FtlTypeInfo {
143        type_kind: TypeKind::Struct,
144        type_name: "InventoryTypeNoFile",
145        variants: VARIANTS_NO_FILE,
146        file_path: "",
147        module_path: "test_crate_empty_file",
148        namespace: None,
149    };
150
151    es_fluent::__inventory::submit! {
152        RegisteredFtlType(&INFO_NO_FILE)
153    }
154
155    fn with_temp_cwd<T>(f: impl FnOnce(&std::path::Path) -> T) -> T {
156        let _guard = crate::TEST_CWD_LOCK.lock().expect("lock poisoned");
157        let original = std::env::current_dir().expect("cwd");
158        let temp = tempdir().expect("tempdir");
159        std::env::set_current_dir(temp.path()).expect("set cwd");
160        let result = f(temp.path());
161        std::env::set_current_dir(original).expect("restore cwd");
162        result
163    }
164
165    #[test]
166    fn write_inventory_for_crate_writes_expected_key_data() {
167        with_temp_cwd(|cwd| {
168            write_inventory_for_crate("test-crate");
169
170            let inventory_path = cwd.join("metadata/test-crate/inventory.json");
171            let content = std::fs::read_to_string(inventory_path).expect("read inventory");
172            let json: serde_json::Value = serde_json::from_str(&content).expect("parse json");
173
174            let keys = json["expected_keys"]
175                .as_array()
176                .expect("expected_keys array");
177            assert_eq!(keys.len(), 1);
178
179            let key = &keys[0];
180            assert_eq!(key["key"], "my_key");
181            assert_eq!(key["source_file"], "src/lib.rs");
182            assert_eq!(key["source_line"], 42);
183
184            let mut vars: Vec<_> = key["variables"]
185                .as_array()
186                .expect("variables array")
187                .iter()
188                .filter_map(|value| value.as_str())
189                .collect();
190            vars.sort_unstable();
191            assert_eq!(vars, vec!["count", "extra", "name"]);
192        });
193    }
194
195    #[test]
196    fn write_inventory_for_unknown_crate_writes_empty_result() {
197        with_temp_cwd(|cwd| {
198            write_inventory_for_crate("unknown-crate");
199            let inventory_path = cwd.join("metadata/unknown-crate/inventory.json");
200            let content = std::fs::read_to_string(inventory_path).expect("read inventory");
201            let json: serde_json::Value = serde_json::from_str(&content).expect("parse json");
202
203            assert_eq!(
204                json["expected_keys"]
205                    .as_array()
206                    .expect("expected_keys")
207                    .len(),
208                0
209            );
210        });
211    }
212
213    #[test]
214    fn write_inventory_sets_source_file_to_null_when_missing() {
215        with_temp_cwd(|cwd| {
216            write_inventory_for_crate("test-crate-empty-file");
217
218            let inventory_path = cwd.join("metadata/test-crate-empty-file/inventory.json");
219            let content = std::fs::read_to_string(inventory_path).expect("read inventory");
220            let json: serde_json::Value = serde_json::from_str(&content).expect("parse json");
221
222            let keys = json["expected_keys"]
223                .as_array()
224                .expect("expected_keys array");
225            assert_eq!(keys.len(), 1);
226
227            let key = &keys[0];
228            assert_eq!(key["key"], "empty_file_key");
229            assert!(key["source_file"].is_null());
230            assert_eq!(key["source_line"], 7);
231        });
232    }
233}