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}