es_fluent_cli/generation/
cache.rs

1//! Caching utilities for CLI performance optimization.
2//!
3//! This module provides caching for expensive operations like:
4//! - Cargo metadata results
5//! - Runner binary staleness detection via content hashing
6
7use indexmap::IndexMap;
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11/// Cache of cargo metadata results.
12///
13/// Stores extracted dependency info keyed by Cargo.lock hash to avoid
14/// running cargo_metadata on every invocation.
15#[derive(Debug, Default, Deserialize, Serialize)]
16pub struct MetadataCache {
17    /// Hash of Cargo.lock when cache was created
18    pub cargo_lock_hash: String,
19    /// Extracted es-fluent dependency string
20    pub es_fluent_dep: String,
21    /// Extracted es-fluent-cli-helpers dependency string
22    pub es_fluent_cli_helpers_dep: String,
23    /// Target directory
24    pub target_dir: String,
25}
26
27impl MetadataCache {
28    const CACHE_FILE: &'static str = "metadata_cache.json";
29
30    /// Load cache from the temp directory.
31    pub fn load(temp_dir: &Path) -> Option<Self> {
32        let cache_path = temp_dir.join(Self::CACHE_FILE);
33        let content = std::fs::read_to_string(&cache_path).ok()?;
34        serde_json::from_str(&content).ok()
35    }
36
37    /// Save cache to the temp directory.
38    pub fn save(&self, temp_dir: &Path) -> std::io::Result<()> {
39        let cache_path = temp_dir.join(Self::CACHE_FILE);
40        let content = serde_json::to_string_pretty(self)?;
41        std::fs::write(cache_path, content)
42    }
43
44    /// Compute hash of Cargo.lock file.
45    pub fn hash_cargo_lock(workspace_root: &Path) -> Option<String> {
46        let lock_path = workspace_root.join("Cargo.lock");
47        let content = std::fs::read(&lock_path).ok()?;
48        Some(blake3::hash(&content).to_hex().to_string())
49    }
50
51    /// Check if the Cargo.lock hash matches the cached one.
52    pub fn is_valid(&self, workspace_root: &Path) -> bool {
53        Self::hash_cargo_lock(workspace_root)
54            .map(|h| h == self.cargo_lock_hash)
55            .unwrap_or(false)
56    }
57}
58
59/// Compute blake3 hash of all .rs files in a source directory, plus the i18n.toml file.
60///
61/// Used for staleness detection - saving a file without modifications
62/// won't change the hash, avoiding unnecessary rebuilds.
63///
64/// The `i18n_toml_path` parameter includes the i18n.toml configuration file
65/// in the hash, so changes to settings like `fluent_feature` trigger rebuilds.
66pub fn compute_content_hash(src_dir: &Path, i18n_toml_path: Option<&Path>) -> String {
67    use blake3::Hasher;
68
69    let mut hasher = Hasher::new();
70    let mut files: Vec<std::path::PathBuf> = Vec::new();
71
72    if src_dir.exists() {
73        let walker = walkdir::WalkDir::new(src_dir);
74        for entry in walker.into_iter().filter_map(|e| e.ok()) {
75            let path = entry.path();
76            if path.is_file() && path.extension().is_some_and(|e| e == "rs") {
77                files.push(path.to_path_buf());
78            }
79        }
80    }
81
82    // Sort for deterministic order
83    files.sort();
84
85    // Hash path + content for each file
86    for path in files {
87        if let Ok(content) = std::fs::read(&path) {
88            hasher.update(path.to_string_lossy().as_bytes());
89            hasher.update(&content);
90        }
91    }
92
93    // Include i18n.toml if provided and exists
94    if let Some(toml_path) = i18n_toml_path
95        && toml_path.is_file()
96        && let Ok(content) = std::fs::read(toml_path)
97    {
98        hasher.update(toml_path.to_string_lossy().as_bytes());
99        hasher.update(&content);
100    }
101
102    hasher.finalize().to_hex().to_string()
103}
104
105/// Runner binary cache tracking which content hashes it was built with.
106///
107/// Stored at the workspace level since the runner is monolithic.
108#[derive(Debug, Default, Deserialize, Serialize)]
109pub struct RunnerCache {
110    /// Map of crate name -> content hash when runner was last built
111    pub crate_hashes: IndexMap<String, String>,
112    /// Mtime of runner binary when cache was created
113    pub runner_mtime: u64,
114    /// Version of es-fluent-cli that built this runner
115    /// Missing/mismatched version triggers rebuild to pick up helper changes
116    #[serde(default)]
117    pub cli_version: String,
118}
119
120impl RunnerCache {
121    const CACHE_FILE: &'static str = "runner_cache.json";
122
123    /// Load cache from the temp directory.
124    pub fn load(temp_dir: &Path) -> Option<Self> {
125        let cache_path = temp_dir.join(Self::CACHE_FILE);
126        let content = std::fs::read_to_string(&cache_path).ok()?;
127        serde_json::from_str(&content).ok()
128    }
129
130    /// Save cache to the temp directory.
131    pub fn save(&self, temp_dir: &Path) -> std::io::Result<()> {
132        let cache_path = temp_dir.join(Self::CACHE_FILE);
133        let content = serde_json::to_string_pretty(self)?;
134        std::fs::write(cache_path, content)
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::fs;
142
143    #[test]
144    fn test_compute_content_hash_without_i18n_toml() {
145        let temp_dir = tempfile::tempdir().unwrap();
146        let src_dir = temp_dir.path().join("src");
147        fs::create_dir_all(&src_dir).unwrap();
148        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();
149
150        let hash1 = compute_content_hash(&src_dir, None);
151        let hash2 = compute_content_hash(&src_dir, None);
152
153        // Same content should produce same hash
154        assert_eq!(hash1, hash2);
155        assert!(!hash1.is_empty());
156    }
157
158    #[test]
159    fn test_compute_content_hash_with_i18n_toml() {
160        let temp_dir = tempfile::tempdir().unwrap();
161        let src_dir = temp_dir.path().join("src");
162        fs::create_dir_all(&src_dir).unwrap();
163        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();
164
165        let i18n_path = temp_dir.path().join("i18n.toml");
166        fs::write(&i18n_path, "default_language = \"en\"").unwrap();
167
168        let hash_with_toml = compute_content_hash(&src_dir, Some(&i18n_path));
169        let hash_without_toml = compute_content_hash(&src_dir, None);
170
171        // Hash should differ when i18n.toml is included
172        assert_ne!(hash_with_toml, hash_without_toml);
173    }
174
175    #[test]
176    fn test_compute_content_hash_changes_when_i18n_toml_changes() {
177        let temp_dir = tempfile::tempdir().unwrap();
178        let src_dir = temp_dir.path().join("src");
179        fs::create_dir_all(&src_dir).unwrap();
180        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();
181
182        let i18n_path = temp_dir.path().join("i18n.toml");
183        fs::write(&i18n_path, "default_language = \"en\"").unwrap();
184
185        let hash1 = compute_content_hash(&src_dir, Some(&i18n_path));
186
187        // Change the i18n.toml content (e.g., changing fluent_feature)
188        fs::write(
189            &i18n_path,
190            "default_language = \"en\"\nfluent_feature = \"i18n\"",
191        )
192        .unwrap();
193
194        let hash2 = compute_content_hash(&src_dir, Some(&i18n_path));
195
196        // Hash should change when i18n.toml content changes
197        assert_ne!(hash1, hash2);
198    }
199
200    #[test]
201    fn test_compute_content_hash_unchanged_when_rs_unchanged() {
202        let temp_dir = tempfile::tempdir().unwrap();
203        let src_dir = temp_dir.path().join("src");
204        fs::create_dir_all(&src_dir).unwrap();
205        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();
206
207        let i18n_path = temp_dir.path().join("i18n.toml");
208        fs::write(&i18n_path, "default_language = \"en\"").unwrap();
209
210        let hash1 = compute_content_hash(&src_dir, Some(&i18n_path));
211
212        // Re-write same content (simulates save without changes)
213        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();
214        fs::write(&i18n_path, "default_language = \"en\"").unwrap();
215
216        let hash2 = compute_content_hash(&src_dir, Some(&i18n_path));
217
218        // Hash should remain the same when content is identical
219        assert_eq!(hash1, hash2);
220    }
221
222    #[test]
223    fn test_compute_content_hash_nonexistent_i18n_toml() {
224        let temp_dir = tempfile::tempdir().unwrap();
225        let src_dir = temp_dir.path().join("src");
226        fs::create_dir_all(&src_dir).unwrap();
227        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();
228
229        let nonexistent_path = temp_dir.path().join("nonexistent.toml");
230
231        // Should not panic and should produce same hash as None
232        let hash_with_nonexistent = compute_content_hash(&src_dir, Some(&nonexistent_path));
233        let hash_without = compute_content_hash(&src_dir, None);
234
235        assert_eq!(hash_with_nonexistent, hash_without);
236    }
237
238    #[test]
239    fn test_compute_content_hash_only_rs_files() {
240        let temp_dir = tempfile::tempdir().unwrap();
241        let src_dir = temp_dir.path().join("src");
242        fs::create_dir_all(&src_dir).unwrap();
243        fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();
244
245        let hash1 = compute_content_hash(&src_dir, None);
246
247        // Add a non-.rs file - should not affect hash
248        fs::write(src_dir.join("notes.txt"), "some notes").unwrap();
249
250        let hash2 = compute_content_hash(&src_dir, None);
251
252        assert_eq!(hash1, hash2);
253    }
254}