mcdata_rs/version.rs
1use crate::data_source;
2use crate::error::McDataError;
3use crate::loader::load_data_from_path;
4use crate::structs::ProtocolVersionInfo;
5use once_cell::sync::OnceCell;
6use std::cmp::Ordering;
7use std::collections::HashMap;
8use std::sync::Arc;
9
10/// Represents the Minecraft edition (PC/Java or Bedrock).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum Edition {
13 Pc,
14 Bedrock,
15}
16
17impl Edition {
18 /// Returns the string prefix used for paths related to this edition.
19 pub fn path_prefix(&self) -> &'static str {
20 match self {
21 Edition::Pc => "pc",
22 Edition::Bedrock => "bedrock",
23 }
24 }
25}
26
27impl std::fmt::Display for Edition {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 write!(f, "{}", self.path_prefix())
30 }
31}
32
33/// Represents a specific Minecraft version with associated metadata.
34///
35/// This struct is used for version comparisons and lookups.
36#[derive(Debug, Clone, PartialEq, Eq, Hash)]
37pub struct Version {
38 /// The user-facing Minecraft version string (e.g., "1.18.2").
39 pub minecraft_version: String,
40 /// The major version string (e.g., "1.18").
41 pub major_version: String,
42 /// The protocol version number.
43 pub version: i32,
44 /// The data version number, used for reliable comparisons between versions of the same edition.
45 /// Higher data versions are newer. This is calculated if missing in source data.
46 pub data_version: i32,
47 /// The edition (PC or Bedrock).
48 pub edition: Edition,
49 /// The release type (e.g., "release", "snapshot").
50 pub release_type: String,
51}
52
53// Implement comparison operators based on `data_version`.
54// Versions from different editions are considered incomparable.
55impl PartialOrd for Version {
56 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
57 if self.edition != other.edition {
58 None // Cannot compare across editions.
59 } else {
60 // Compare based on data_version.
61 self.data_version.partial_cmp(&other.data_version)
62 }
63 }
64}
65impl Ord for Version {
66 fn cmp(&self, other: &Self) -> Ordering {
67 self.partial_cmp(other).unwrap_or_else(|| {
68 // If editions differ, log a warning and treat them as equal for sorting purposes,
69 // although semantically they are incomparable.
70 log::warn!(
71 "Comparing Version structs from different editions ({:?} vs {:?})",
72 self.edition,
73 other.edition
74 );
75 Ordering::Equal
76 })
77 }
78}
79
80// --- Static Loading and Caching of Version Data ---
81
82// Cache for loaded and indexed version data, keyed by edition.
83// Stores the Result to cache loading errors as well.
84static LOADED_VERSIONS: OnceCell<HashMap<Edition, Result<Arc<VersionData>, McDataError>>> =
85 OnceCell::new();
86
87/// Holds indexed version data for efficient lookups.
88#[derive(Debug, Clone)]
89pub struct VersionData {
90 /// Maps Minecraft version string (e.g., "1.18.2", "1.19") to the corresponding `Version`.
91 /// Major version keys map to the latest *release* within that major version.
92 pub by_minecraft_version: HashMap<String, Version>,
93 /// Maps major version string (e.g., "1.18") to a list of all `Version`s within that major version,
94 /// sorted newest first (by data_version).
95 pub by_major_version: HashMap<String, Vec<Version>>,
96 /// Maps protocol version number to a list of `Version`s sharing that protocol number,
97 /// sorted newest first (by data_version).
98 pub by_protocol_version: HashMap<i32, Vec<Version>>,
99}
100
101/// Loads `protocolVersions.json` for the given edition, calculates missing `data_version`s,
102/// and indexes the data into a `VersionData` struct.
103fn load_and_index_versions(edition: Edition) -> Result<Arc<VersionData>, McDataError> {
104 log::debug!(
105 "Attempting to load protocolVersions.json for {:?}...",
106 edition
107 );
108 let data_root = data_source::get_data_root()?;
109 let path_str = format!("{}/common/protocolVersions.json", edition.path_prefix());
110 let path = data_root.join(path_str);
111
112 // Load the raw version info from the JSON file.
113 let mut raw_versions: Vec<ProtocolVersionInfo> = load_data_from_path(&path)?;
114
115 // Calculate `data_version` if missing. This is crucial for reliable comparisons.
116 // We assign decreasing negative numbers based on reverse protocol version order.
117 // Sort by protocol version descending first to ensure consistent assignment.
118 raw_versions.sort_by(|a, b| b.version.cmp(&a.version));
119 for (i, v) in raw_versions.iter_mut().enumerate() {
120 if v.data_version.is_none() {
121 // Assign a synthetic, negative data_version for older entries lacking one.
122 v.data_version = Some(-(i as i32));
123 log::trace!(
124 "Assigned synthetic data_version {} to {}",
125 v.data_version.unwrap(),
126 v.minecraft_version
127 );
128 }
129 }
130
131 // Initialize index maps.
132 let mut by_mc_ver = HashMap::new();
133 let mut by_major = HashMap::<String, Vec<Version>>::new();
134 let mut by_proto = HashMap::<i32, Vec<Version>>::new();
135
136 // Process each raw version entry and populate the indexes.
137 for raw in raw_versions {
138 // Ensure data_version was present or calculated.
139 let data_version = raw.data_version.ok_or_else(|| {
140 McDataError::Internal(format!("Missing dataVersion for {}", raw.minecraft_version))
141 })?;
142
143 let v = Version {
144 minecraft_version: raw.minecraft_version.clone(),
145 major_version: raw.major_version.clone(),
146 version: raw.version,
147 data_version,
148 edition,
149 release_type: raw.release_type.clone(),
150 };
151
152 // Index by full Minecraft version string (e.g., "1.18.2"). Overwrite if duplicate.
153 by_mc_ver.insert(raw.minecraft_version.clone(), v.clone());
154
155 // Index by major version string (e.g., "1.18") to point to the *latest release* within that major version.
156 // This allows resolving "1.18" to the newest actual release like "1.18.2".
157 by_mc_ver
158 .entry(raw.major_version.clone())
159 .and_modify(|existing| {
160 // Update only if the current version `v` is newer AND is a release,
161 // OR if `v` is newer and the existing entry is not a release (prefer releases).
162 if (v.data_version > existing.data_version && v.release_type == "release")
163 || (v.data_version > existing.data_version
164 && existing.release_type != "release")
165 {
166 *existing = v.clone();
167 }
168 })
169 .or_insert_with(|| v.clone()); // Insert if the major version key doesn't exist yet.
170
171 // Index by major_version, collecting all versions (including snapshots) for that major.
172 by_major
173 .entry(raw.major_version)
174 .or_default()
175 .push(v.clone());
176
177 // Index by protocol_version, collecting all versions sharing that protocol number.
178 by_proto.entry(raw.version).or_default().push(v);
179 }
180
181 // Sort the vectors within the maps by data_version descending (newest first).
182 for versions in by_major.values_mut() {
183 versions.sort_unstable_by(|a, b| b.cmp(a));
184 }
185 for versions in by_proto.values_mut() {
186 versions.sort_unstable_by(|a, b| b.cmp(a));
187 }
188
189 Ok(Arc::new(VersionData {
190 by_minecraft_version: by_mc_ver,
191 by_major_version: by_major,
192 by_protocol_version: by_proto,
193 }))
194}
195
196/// Gets the cached `VersionData` for the specified edition.
197/// Loads and caches data for both editions on the first call.
198pub fn get_version_data(edition: Edition) -> Result<Arc<VersionData>, McDataError> {
199 let cache = LOADED_VERSIONS.get_or_init(|| {
200 log::debug!("Initializing version cache map");
201 // Pre-populate both editions on first access to avoid multiple init attempts.
202 let mut map = HashMap::new();
203 map.insert(Edition::Pc, load_and_index_versions(Edition::Pc));
204 map.insert(Edition::Bedrock, load_and_index_versions(Edition::Bedrock));
205 map
206 });
207
208 // Retrieve the result for the requested edition from the initialized cache.
209 match cache.get(&edition) {
210 Some(Ok(arc_data)) => Ok(arc_data.clone()),
211 Some(Err(original_error)) => Err(McDataError::Internal(format!(
212 "Failed to load protocol versions for {:?} during initialization: {}",
213 edition, original_error
214 ))),
215 None => Err(McDataError::Internal(format!(
216 "Version data for edition {:?} unexpectedly missing from cache.",
217 edition
218 ))),
219 }
220}
221
222/// Resolves a version string (like "1.18.2", "pc_1.16.5", "1.19", or a protocol number)
223/// into a canonical `Version` struct.
224///
225/// It attempts resolution in the following order:
226/// 1. Direct lookup by Minecraft version string (e.g., "1.18.2").
227/// 2. Lookup by protocol version number (preferring release versions).
228/// 3. Lookup by major version string (e.g., "1.19"), resolving to the latest release within that major version.
229/// 4. Fallback lookup by major version string, resolving to the absolute newest version (including snapshots) if no release was found in step 3.
230pub fn resolve_version(version_str: &str) -> Result<Version, McDataError> {
231 log::debug!("Resolving version string: '{}'", version_str);
232 let (edition, version_part) = parse_version_string(version_str)?;
233 let version_data = get_version_data(edition)?;
234
235 // 1. Try direct Minecraft version lookup (e.g., "1.18.2").
236 if let Some(version) = version_data.by_minecraft_version.get(version_part) {
237 // Check if the key used was the actual minecraft_version or a major version key.
238 // If it was a major version key, the stored version should be the latest release.
239 if version.minecraft_version == version_part || version.major_version == version_part {
240 log::trace!(
241 "Resolved '{}' via direct/major Minecraft version lookup to {}",
242 version_str,
243 version.minecraft_version
244 );
245 return Ok(version.clone());
246 }
247 // If the key matched but wasn't the exact mc version or major version,
248 // it might be an older entry (e.g., a .0 version). Let subsequent steps handle it.
249 }
250
251 // 2. Try parsing as protocol version number.
252 if let Ok(protocol_num) = version_part.parse::<i32>() {
253 if let Some(versions) = version_data.by_protocol_version.get(&protocol_num) {
254 // Find the best match: prefer the latest release version, otherwise take the absolute newest.
255 if let Some(best_match) = versions
256 .iter()
257 .find(|v| v.release_type == "release") // Find latest release first
258 .or_else(|| versions.first())
259 // Fallback to newest overall if no release
260 {
261 log::trace!(
262 "Resolved '{}' via protocol number {} lookup to {}",
263 version_str,
264 protocol_num,
265 best_match.minecraft_version
266 );
267 return Ok(best_match.clone());
268 }
269 }
270 }
271
272 // 3. Try major version lookup again, specifically checking the by_major_version map.
273 // This handles cases where the major version string itself wasn't a direct key in by_minecraft_version
274 // or if we need the absolute newest entry (including snapshots) for that major.
275 if let Some(versions) = version_data.by_major_version.get(version_part) {
276 if let Some(newest) = versions.first() {
277 // Versions are sorted newest first.
278 log::trace!(
279 "Resolved '{}' via by_major_version map (newest is {})",
280 version_str,
281 newest.minecraft_version
282 );
283 // Return the absolute newest version found for this major.
284 return Ok(newest.clone());
285 }
286 }
287
288 log::warn!(
289 "Failed to resolve version string '{}' for edition {:?}",
290 version_str,
291 edition
292 );
293 Err(McDataError::InvalidVersion(version_str.to_string()))
294}
295
296/// Parses a version string, extracting the edition and the version part.
297/// Defaults to PC edition if no prefix ("pc_" or "bedrock_") is found.
298fn parse_version_string(version_str: &str) -> Result<(Edition, &str), McDataError> {
299 if let Some(stripped) = version_str.strip_prefix("pc_") {
300 Ok((Edition::Pc, stripped))
301 } else if let Some(stripped) = version_str.strip_prefix("bedrock_") {
302 Ok((Edition::Bedrock, stripped))
303 } else {
304 // Assume PC edition if no prefix is present.
305 // The subsequent `resolve_version` logic will determine if the version part is valid for PC.
306 log::trace!(
307 "Assuming PC edition for version string '{}' (no prefix found)",
308 version_str
309 );
310 Ok((Edition::Pc, version_str))
311 }
312}
313
314/// Returns a sorted list of all known specific Minecraft version strings for an edition.
315/// Versions are sorted chronologically (oldest first) based on a basic semver-like comparison.
316pub fn get_supported_versions(edition: Edition) -> Result<Vec<String>, McDataError> {
317 let version_data = get_version_data(edition)?;
318
319 // Extract specific version strings (containing '.') from the indexed data.
320 // This filters out major version keys like "1.18" that might be in the map.
321 let mut versions: Vec<_> = version_data
322 .by_minecraft_version
323 .values()
324 .filter(|v| v.minecraft_version.contains('.'))
325 .map(|v| v.minecraft_version.clone())
326 .collect();
327
328 // Sort the versions. A simple numeric comparison of parts usually works well.
329 versions.sort_by(|a, b| {
330 let parts_a: Vec<Option<u32>> = a.split('.').map(|s| s.parse().ok()).collect();
331 let parts_b: Vec<Option<u32>> = b.split('.').map(|s| s.parse().ok()).collect();
332 let len = std::cmp::max(parts_a.len(), parts_b.len());
333 for i in 0..len {
334 // Treat missing parts or non-numeric parts as 0 for comparison.
335 let val_a = parts_a.get(i).cloned().flatten().unwrap_or(0);
336 let val_b = parts_b.get(i).cloned().flatten().unwrap_or(0);
337 match val_a.cmp(&val_b) {
338 Ordering::Equal => continue, // If parts are equal, compare the next part.
339 other => return other, // Otherwise, return the comparison result.
340 }
341 }
342 Ordering::Equal // If all parts are equal, the versions are considered equal.
343 });
344
345 // Return sorted list (oldest first).
346 Ok(versions)
347}