mcdata_rs/
lib.rs

1use log;
2use once_cell::sync::Lazy;
3use std::collections::HashMap;
4use std::sync::{Arc, RwLock};
5
6// Module definitions
7mod cached_data;
8mod data_source;
9mod error;
10mod features;
11mod indexer;
12mod loader;
13mod paths;
14mod structs;
15mod version;
16
17// Public API exports
18pub use cached_data::IndexedData;
19pub use error::{Edition, McDataError};
20pub use structs::*;
21pub use version::Version; // Re-export all data structs
22
23// Global cache for loaded and indexed data, keyed by canonical version string (e.g., "pc_1.18.2").
24// Uses a RwLock to allow concurrent reads while ensuring safe writes.
25static DATA_CACHE: Lazy<RwLock<HashMap<String, Arc<IndexedData>>>> = Lazy::new(Default::default);
26
27/// The main entry point to get Minecraft data for a specific version.
28///
29/// Accepts version strings like "1.18.2", "pc_1.16.5", "bedrock_1.17.10", "1.19".
30/// Handles caching of loaded data automatically.
31/// On first use for a specific version (or if data is missing from the local cache),
32/// it may download the required data files from the PrismarineJS/minecraft-data repository
33/// and store them in a local cache directory (typically within the system's cache location).
34///
35/// # Errors
36///
37/// Returns `McDataError` if:
38/// *   The version string is invalid or cannot be resolved to a known Minecraft version.
39/// *   Network errors occur during the initial data download.
40/// *   Filesystem errors occur while accessing or writing to the cache directory.
41/// *   Required data files are missing or corrupt (e.g., JSON parsing errors).
42/// *   Internal errors occur (e.g., cache lock poisoning).
43pub fn mc_data(version_str: &str) -> Result<Arc<IndexedData>, McDataError> {
44    // 1. Resolve the input version string to a canonical `Version` struct.
45    // This step might trigger initial download/loading of version metadata if not already cached.
46    let version = version::resolve_version(version_str)?;
47    let cache_key = format!(
48        "{}_{}",
49        version.edition.path_prefix(),
50        version.minecraft_version
51    );
52    log::debug!("Requesting data for resolved version key: {}", cache_key);
53
54    // 2. Check the cache for existing data using a read lock.
55    {
56        let cache = DATA_CACHE
57            .read()
58            .map_err(|_| McDataError::Internal("Data cache read lock poisoned".to_string()))?;
59        if let Some(data) = cache.get(&cache_key) {
60            log::info!("Cache hit for version: {}", cache_key);
61            return Ok(data.clone()); // Return the cached Arc.
62        }
63    } // Read lock is released here.
64
65    // 3. Cache miss: Load and index the data for this version.
66    // This involves reading files, parsing JSON, and building index HashMaps.
67    // This operation happens outside the write lock to avoid holding it during potentially long I/O.
68    log::info!("Cache miss for version: {}. Loading...", cache_key);
69    let loaded_data_result = IndexedData::load(version); // This function handles loading all necessary files.
70
71    // Handle potential errors during the loading process before attempting to cache.
72    let loaded_data = match loaded_data_result {
73        Ok(data) => Arc::new(data),
74        Err(e) => {
75            log::error!("Failed to load data for {}: {}", cache_key, e);
76            return Err(e); // Propagate the loading error.
77        }
78    };
79
80    // 4. Acquire write lock to insert the newly loaded data into the cache.
81    {
82        let mut cache = DATA_CACHE
83            .write()
84            .map_err(|_| McDataError::Internal("Data cache write lock poisoned".to_string()))?;
85        // Double-check: Another thread might have loaded and inserted the data
86        // while this thread was performing the load operation.
87        if let Some(data) = cache.get(&cache_key) {
88            log::info!("Cache hit after load race for version: {}", cache_key);
89            return Ok(data.clone()); // Return the data loaded by the other thread.
90        }
91        // Insert the data loaded by this thread.
92        log::info!(
93            "Inserting loaded data into cache for version: {}",
94            cache_key
95        );
96        cache.insert(cache_key.clone(), loaded_data.clone());
97    } // Write lock is released here.
98
99    Ok(loaded_data)
100}
101
102/// Returns a list of supported Minecraft versions for the given edition,
103/// sorted oldest to newest based on available data in `protocolVersions.json`.
104///
105/// This may trigger data download on the first call if version information isn't cached.
106///
107/// # Errors
108/// Returns `McDataError` if version information cannot be loaded (e.g., download failure, file corruption).
109pub fn supported_versions(edition: Edition) -> Result<Vec<String>, McDataError> {
110    version::get_supported_versions(edition)
111}
112
113// --- Tests ---
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::path::PathBuf;
118
119    // Initializes logging for test output.
120    fn setup() {
121        // Use try_init to avoid panic if logger is already initialized by another test.
122        let _ = env_logger::builder().is_test(true).try_init();
123    }
124
125    // Helper to get the expected cache directory path used during tests.
126    fn get_test_cache_dir() -> Option<PathBuf> {
127        dirs_next::cache_dir().map(|p| p.join("mcdata-rs").join("minecraft-data"))
128    }
129
130    // Utility function to clear the cache directory (use with caution, especially in parallel tests).
131    #[allow(dead_code)]
132    fn clear_test_cache() {
133        if let Some(cache_dir) = get_test_cache_dir() {
134            if cache_dir.exists() {
135                log::warn!("Clearing test cache directory: {}", cache_dir.display());
136                if let Err(e) = std::fs::remove_dir_all(&cache_dir) {
137                    log::error!("Failed to clear test cache: {}", e);
138                }
139            }
140        }
141    }
142
143    #[test]
144    fn load_pc_1_18_2() {
145        setup();
146        let data = mc_data("1.18.2").expect("Failed to load 1.18.2 data");
147        assert_eq!(data.version.minecraft_version, "1.18.2");
148        assert_eq!(data.version.edition, Edition::Pc);
149        let stone = data
150            .blocks_by_name
151            .get("stone")
152            .expect("Stone block not found");
153        assert_eq!(stone.id, 1);
154        assert!(
155            data.items_by_name.contains_key("stick"),
156            "Stick item not found by name"
157        );
158        assert!(!data.biomes_array.is_empty(), "Biomes empty");
159        assert!(!data.entities_array.is_empty(), "Entities empty");
160        assert!(
161            data.block_collision_shapes_raw.is_some(),
162            "Collision shapes missing"
163        );
164        assert!(
165            !data.block_shapes_by_name.is_empty(),
166            "Indexed shapes empty"
167        );
168    }
169
170    #[test]
171    fn load_pc_major_version() {
172        setup();
173        // Should resolve to the latest release within the 1.19 major version.
174        let data = mc_data("1.19").expect("Failed to load 1.19 data");
175        assert!(data.version.minecraft_version.starts_with("1.19"));
176        assert_eq!(data.version.edition, Edition::Pc);
177        assert!(data.blocks_by_name.contains_key("mangrove_log"));
178        assert!(data.entities_by_name.contains_key("warden"));
179    }
180
181    #[test]
182    fn test_version_comparison() {
183        setup();
184        let data_1_18 = mc_data("1.18.2").unwrap();
185        let data_1_16 = mc_data("1.16.5").unwrap();
186        let data_1_20 = mc_data("1.20.1").unwrap(); // Assumes 1.20.1 data exists
187
188        assert!(data_1_18.is_newer_or_equal_to("1.16.5").unwrap());
189        assert!(data_1_18.is_newer_or_equal_to("1.18.2").unwrap());
190        assert!(!data_1_18.is_newer_or_equal_to("1.20.1").unwrap());
191        assert!(data_1_20.is_newer_or_equal_to("1.18.2").unwrap());
192
193        assert!(data_1_16.is_older_than("1.18.2").unwrap());
194        assert!(!data_1_16.is_older_than("1.16.5").unwrap());
195        assert!(!data_1_16.is_older_than("1.15.2").unwrap()); // Assumes 1.15.2 data exists
196        assert!(data_1_18.is_older_than("1.20.1").unwrap());
197    }
198
199    #[test]
200    fn test_feature_support() {
201        setup();
202        let data_1_18 = mc_data("1.18.2").unwrap();
203        let data_1_15 = mc_data("1.15.2").unwrap();
204
205        // Check a boolean feature that changes across versions.
206        let dim_int_115 = data_1_15.support_feature("dimensionIsAnInt").unwrap();
207        assert_eq!(dim_int_115, serde_json::Value::Bool(true));
208
209        let dim_int_118 = data_1_18.support_feature("dimensionIsAnInt").unwrap();
210        assert_eq!(dim_int_118, serde_json::Value::Bool(false));
211
212        // Check a feature with a numeric value that changes.
213        let meta_ix_118 = data_1_18.support_feature("metadataIxOfItem").unwrap();
214        assert_eq!(meta_ix_118, serde_json::Value::Number(8.into()));
215
216        let meta_ix_115 = data_1_15.support_feature("metadataIxOfItem").unwrap();
217        assert_eq!(meta_ix_115, serde_json::Value::Number(7.into()));
218    }
219
220    #[test]
221    fn test_cache() {
222        setup();
223        let version = "1.17.1";
224        log::info!("CACHE TEST: Loading {} for the first time", version);
225        let data1 = mc_data(version).expect("Load 1 failed");
226        log::info!("CACHE TEST: Loading {} for the second time", version);
227        let data2 = mc_data(version).expect("Load 2 failed");
228        // Check if both results point to the same Arc allocation (indicates cache hit).
229        assert!(
230            Arc::ptr_eq(&data1, &data2),
231            "Cache miss: Arcs point to different data for {}",
232            version
233        );
234
235        // Test that resolving a prefixed version hits the same cache entry.
236        let prefixed_version = format!("pc_{}", version);
237        log::info!(
238            "CACHE TEST: Loading {} for the third time",
239            prefixed_version
240        );
241        let data3 = mc_data(&prefixed_version).expect("Load 3 failed");
242        assert!(
243            Arc::ptr_eq(&data1, &data3),
244            "Cache miss: Prefixed version {} loaded different data",
245            prefixed_version
246        );
247    }
248
249    #[test]
250    fn test_supported_versions() {
251        setup();
252        let versions =
253            supported_versions(Edition::Pc).expect("Failed to get supported PC versions");
254        assert!(!versions.is_empty());
255        // Check if some expected versions are present.
256        assert!(versions.iter().any(|v| v == "1.8.8"));
257        assert!(versions.iter().any(|v| v == "1.16.5"));
258        assert!(versions.iter().any(|v| v == "1.18.2"));
259        assert!(versions.iter().any(|v| v == "1.20.1"));
260
261        // Check sorting (basic check: 1.8.8 should appear before 1.16.5).
262        let index_1_8 = versions.iter().position(|v| v == "1.8.8");
263        let index_1_16 = versions.iter().position(|v| v == "1.16.5");
264        assert!(index_1_8.is_some());
265        assert!(index_1_16.is_some());
266        assert!(
267            index_1_8 < index_1_16,
268            "Versions should be sorted oldest to newest"
269        );
270    }
271
272    #[test]
273    fn test_invalid_version() {
274        setup();
275        let result = mc_data("invalid_version_string_1.2.3");
276        assert!(result.is_err());
277        match result.err().unwrap() {
278            McDataError::InvalidVersion(s) => assert!(s.contains("invalid_version")),
279            e => panic!("Expected InvalidVersion error, got {:?}", e),
280        }
281    }
282
283    // Placeholder for Bedrock tests when data/support is confirmed.
284    // #[test]
285    // fn load_bedrock_version() {
286    //     setup();
287    //     let version = "bedrock_1.18.30"; // Use a known valid Bedrock version
288    //     let data = mc_data(version).expect("Failed to load Bedrock data");
289    //     assert_eq!(data.version.edition, Edition::Bedrock);
290    //     assert!(data.version.minecraft_version.contains("1.18.30"));
291    //     assert!(!data.blocks_array.is_empty());
292    //     assert!(!data.items_array.is_empty());
293    // }
294}