Skip to main content

java_manager/
spec.rs

1//! Human-readable Java specification parsing and matching.
2//!
3//! A spec string like `"Eclipse Adoptium v^17.0.0 ax86_64"` describes a
4//! desired Java installation by vendor, version constraint, and architecture.
5//!
6//! Format: `<vendor> v<constraint> a<arch>`
7//!
8//! | Part | Example | Meaning |
9//! |---|---|---|
10//! | Vendor | `Eclipse Adoptium` | Fuzzy-matched against name / vendor |
11//! | Constraint | `^17.0.0` | Compatible with 17.0.0 (semver caret) |
12//! | Architecture | `x86_64` | Architecture substring match |
13//!
14//! Supported constraint operators:
15//! - `^17.0.0` — compatible (≥17.0.0, <18.0.0)
16//! - `~17.0.0` — approximately (≥17.0.0, <17.1.0)
17//! - `>=17.0.0`, `<=17.0.0`, `>17.0.0`, `<17.0.0`
18//! - `17.0.0` — exact (no prefix)
19
20use std::fmt;
21
22/// A parsed Java installation query spec.
23#[derive(Debug, Clone)]
24pub struct JavaSpec {
25    /// Vendor substring to match (case-insensitive).
26    pub vendor: Option<String>,
27    /// Version requirement.
28    pub version: Option<VersionReq>,
29    /// Architecture substring to match (case-insensitive).
30    pub arch: Option<String>,
31    /// The raw input string.
32    pub raw: String,
33}
34
35/// A version constraint with an operator and target version.
36#[derive(Debug, Clone, PartialEq)]
37pub enum VersionReq {
38    /// `^major.minor.patch` — compatible: >= target and < next-major.
39    Compatible { major: u64, minor: u64, patch: u64 },
40    /// `~major.minor.patch` — approximately: >= target and < next-minor.
41    Approx { major: u64, minor: u64, patch: u64 },
42    /// `>=major.minor.patch`
43    Gte { major: u64, minor: u64, patch: u64 },
44    /// `<=major.minor.patch`
45    Lte { major: u64, minor: u64, patch: u64 },
46    /// `>major.minor.patch`
47    Gt { major: u64, minor: u64, patch: u64 },
48    /// `<major.minor.patch`
49    Lt { major: u64, minor: u64, patch: u64 },
50    /// `major.minor.patch` — exact match.
51    Exact { major: u64, minor: u64, patch: u64 },
52}
53
54impl fmt::Display for VersionReq {
55    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
56        match self {
57            VersionReq::Compatible {
58                major,
59                minor,
60                patch,
61            } => {
62                write!(f, "^{}.{}.{}", major, minor, patch)
63            }
64            VersionReq::Approx {
65                major,
66                minor,
67                patch,
68            } => {
69                write!(f, "~{}.{}.{}", major, minor, patch)
70            }
71            VersionReq::Gte {
72                major,
73                minor,
74                patch,
75            } => write!(f, ">={}.{}.{}", major, minor, patch),
76            VersionReq::Lte {
77                major,
78                minor,
79                patch,
80            } => write!(f, "<={}.{}.{}", major, minor, patch),
81            VersionReq::Gt {
82                major,
83                minor,
84                patch,
85            } => write!(f, ">{}.{}.{}", major, minor, patch),
86            VersionReq::Lt {
87                major,
88                minor,
89                patch,
90            } => write!(f, "<{}.{}.{}", major, minor, patch),
91            VersionReq::Exact {
92                major,
93                minor,
94                patch,
95            } => write!(f, "{}.{}.{}", major, minor, patch),
96        }
97    }
98}
99
100impl JavaSpec {
101    /// Parse a human-readable Java spec string.
102    ///
103    /// # Format
104    ///
105    /// `<vendor> v<constraint> a<arch>`
106    ///
107    /// Both `v` and `a` prefixes and their values are optional.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// use java_manager::JavaSpec;
113    ///
114    /// let spec = JavaSpec::parse("Eclipse Adoptium v^17.0.0 ax86_64").unwrap();
115    /// assert_eq!(spec.vendor.as_deref(), Some("Eclipse Adoptium"));
116    /// assert!(spec.version.is_some());
117    /// assert_eq!(spec.arch.as_deref(), Some("x86_64"));
118    /// ```
119    pub fn parse(input: &str) -> Result<Self, String> {
120        let trimmed = input.trim();
121        let raw = trimmed.to_string();
122
123        let (vendor, rest) = split_vendor(trimmed);
124        let (version, rest) = parse_version(rest);
125
126        // If no version was found and there was no vendor split,
127        // the remaining text could be a vendor name or an architecture.
128        if vendor.is_none() && version.is_none() && !rest.is_empty() {
129            // Check if it's an architecture pattern first (short, no spaces).
130            if looks_like_arch(rest) {
131                let arch = parse_arch(rest);
132                return Ok(JavaSpec {
133                    vendor: None,
134                    version: None,
135                    arch,
136                    raw,
137                });
138            }
139            // Otherwise treat as vendor name.
140            return Ok(JavaSpec {
141                vendor: Some(rest.to_string()),
142                version: None,
143                arch: None,
144                raw,
145            });
146        }
147
148        let arch = parse_arch(rest);
149
150        Ok(JavaSpec {
151            vendor,
152            version,
153            arch,
154            raw,
155        })
156    }
157
158    /// Check whether a [`JavaInfo`] matches this spec.
159    ///
160    /// All present criteria (vendor, version, architecture) must match.
161    /// Missing criteria are ignored (i.e., a spec with no vendor matches
162    /// any vendor).
163    ///
164    /// Vendor matching is case-insensitive substring.
165    /// Architecture matching is case-insensitive substring.
166    pub fn matches(&self, info: &crate::JavaInfo) -> bool {
167        if let Some(ref v) = self.vendor {
168            let name_match = info.name.to_lowercase().contains(&v.to_lowercase());
169            let vendor_match = info.vendor.to_lowercase().contains(&v.to_lowercase());
170            if !name_match && !vendor_match {
171                return false;
172            }
173        }
174
175        if let Some(ref req) = self.version
176            && !matches_version_req(&info.parsed_version, req)
177        {
178            return false;
179        }
180
181        if let Some(ref a) = self.arch {
182            let arch_lower = info.architecture.to_lowercase();
183            if !arch_lower.contains(&a.to_lowercase()) {
184                return false;
185            }
186        }
187
188        true
189    }
190}
191
192/// Split input into vendor portion (before ` v`) and the rest.
193///
194/// Only splits on ` v` when what follows looks like a version expression
195/// (starts with `v^`, `v~`, `v>=`, `v<=`, `v>`, `v<`, or a digit after `v`).
196fn split_vendor(input: &str) -> (Option<String>, &str) {
197    // Look for " v" where the v is followed by version-like characters
198    if let Some(idx) = find_version_prefix(input) {
199        let vendor = input[..idx].trim();
200        let rest = input[idx + 1..].trim(); // skip " v"
201        let vendor = if vendor.is_empty() {
202            None
203        } else {
204            Some(vendor.to_string())
205        };
206        (vendor, rest)
207    } else {
208        (None, input)
209    }
210}
211
212/// Find the position of ` v` that introduces a version expression.
213fn find_version_prefix(input: &str) -> Option<usize> {
214    let bytes = input.as_bytes();
215    // We need to find " v" followed by version-indicating characters
216    for i in 0..bytes.len().saturating_sub(2) {
217        if bytes[i] == b' ' && bytes[i + 1] == b'v' {
218            // Check what follows the 'v'
219            let after = &bytes[i + 2..];
220            if after.is_empty() {
221                continue;
222            }
223            // Version can start with operator: ^ ~ >= <= > < or digit
224            let next = after[0];
225            if next.is_ascii_digit() || next == b'^' || next == b'~' || next == b'>' || next == b'<'
226            {
227                return Some(i);
228            }
229            // If 'v' is followed by >= or <=
230            if after.len() >= 2 && (next == b'>' || next == b'<') && after[1] == b'=' {
231                return Some(i);
232            }
233        }
234    }
235    None
236}
237
238/// Parse version constraint from the start of `input`.
239///
240/// Returns the parsed requirement (if any) and the remaining string.
241fn parse_version(input: &str) -> (Option<VersionReq>, &str) {
242    // Strip optional leading 'v' prefix
243    let input = input.strip_prefix('v').unwrap_or(input).trim_start();
244
245    if input.is_empty() {
246        return (None, input);
247    }
248
249    // Check if it starts with a known operator
250    if let Some(rest) = input.strip_prefix("^") {
251        let (v, rest) = parse_version_number(rest);
252        let req = v.map(|(major, minor, patch)| VersionReq::Compatible {
253            major,
254            minor,
255            patch,
256        });
257        return (req, rest);
258    }
259    if let Some(rest) = input.strip_prefix("~") {
260        let (v, rest) = parse_version_number(rest);
261        let req = v.map(|(major, minor, patch)| VersionReq::Approx {
262            major,
263            minor,
264            patch,
265        });
266        return (req, rest);
267    }
268    if let Some(rest) = input.strip_prefix(">=") {
269        let (v, rest) = parse_version_number(rest);
270        let req = v.map(|(major, minor, patch)| VersionReq::Gte {
271            major,
272            minor,
273            patch,
274        });
275        return (req, rest);
276    }
277    if let Some(rest) = input.strip_prefix("<=") {
278        let (v, rest) = parse_version_number(rest);
279        let req = v.map(|(major, minor, patch)| VersionReq::Lte {
280            major,
281            minor,
282            patch,
283        });
284        return (req, rest);
285    }
286    if let Some(rest) = input.strip_prefix(">") {
287        let (v, rest) = parse_version_number(rest);
288        let req = v.map(|(major, minor, patch)| VersionReq::Gt {
289            major,
290            minor,
291            patch,
292        });
293        return (req, rest);
294    }
295    if let Some(rest) = input.strip_prefix("<") {
296        let (v, rest) = parse_version_number(rest);
297        let req = v.map(|(major, minor, patch)| VersionReq::Lt {
298            major,
299            minor,
300            patch,
301        });
302        return (req, rest);
303    }
304
305    // No operator — try parsing as exact version
306    let (v, rest) = parse_version_number(input);
307    (
308        v.map(|(maj, min, patch)| VersionReq::Exact {
309            major: maj,
310            minor: min,
311            patch,
312        }),
313        rest,
314    )
315}
316
317/// Parse a version number in the form `major.minor.patch` from the start.
318///
319/// Returns the parsed components and the remaining string.
320fn parse_version_number(input: &str) -> (Option<(u64, u64, u64)>, &str) {
321    let trimmed = input.trim_start();
322
323    // Collect digits and dots until we hit a non-version character
324    let version_end = trimmed
325        .find(|c: char| !c.is_ascii_digit() && c != '.')
326        .unwrap_or(trimmed.len());
327    let version_part = &trimmed[..version_end];
328    let rest = trimmed[version_end..].trim_start();
329
330    let parts: Vec<&str> = version_part.split('.').collect();
331    if parts.is_empty() || parts[0].is_empty() {
332        return (None, input);
333    }
334
335    let major = match parts[0].parse() {
336        Ok(m) => m,
337        Err(_) => return (None, input),
338    };
339    let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
340    let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
341
342    (Some((major, minor, patch)), rest)
343}
344
345/// Parse architecture from the input (may start with `a` prefix).
346fn parse_arch(input: &str) -> Option<String> {
347    let trimmed = input.trim();
348    if trimmed.is_empty() {
349        return None;
350    }
351
352    // Strip leading 'a' prefix if present (e.g. "ax86_64" -> "x86_64")
353    // But keep "aarch64" as-is since that's the actual arch name
354    let arch = if trimmed.len() > 1 && trimmed.starts_with('a') && trimmed != "aarch64" {
355        trimmed[1..].to_string()
356    } else {
357        trimmed.to_string()
358    };
359
360    if arch.is_empty() { None } else { Some(arch) }
361}
362
363/// Check if a string looks like an architecture name.
364fn looks_like_arch(input: &str) -> bool {
365    let trimmed = input.trim();
366    if trimmed.contains(' ') {
367        return false; // architectures don't have spaces
368    }
369    let lower = trimmed.to_lowercase();
370    let known = ["x86_64", "amd64", "aarch64", "arm64", "x86", "i386", "i686"];
371    if known.contains(&lower.as_str()) {
372        return true;
373    }
374    // Pattern: optional 'a' prefix + known arch name
375    if trimmed.starts_with('a') || trimmed.starts_with('A') {
376        let rest = &trimmed[1..];
377        let rest_lower = rest.to_lowercase();
378        if known.contains(&rest_lower.as_str()) {
379            return true;
380        }
381    }
382    false
383}
384
385/// Check if parsed_version matches a version requirement.
386fn matches_version_req(parsed: &Option<crate::JavaVersion>, req: &VersionReq) -> bool {
387    let parsed = match parsed {
388        Some(v) => v,
389        None => return false,
390    };
391
392    let (req_major, req_minor, req_patch) = match *req {
393        VersionReq::Compatible {
394            major,
395            minor,
396            patch,
397        }
398        | VersionReq::Approx {
399            major,
400            minor,
401            patch,
402        }
403        | VersionReq::Gte {
404            major,
405            minor,
406            patch,
407        }
408        | VersionReq::Lte {
409            major,
410            minor,
411            patch,
412        }
413        | VersionReq::Gt {
414            major,
415            minor,
416            patch,
417        }
418        | VersionReq::Lt {
419            major,
420            minor,
421            patch,
422        }
423        | VersionReq::Exact {
424            major,
425            minor,
426            patch,
427        } => (major, minor, patch),
428    };
429
430    let parsed_tuple = (parsed.major, parsed.minor, parsed.patch);
431
432    match *req {
433        VersionReq::Compatible { .. } => {
434            parsed_tuple >= (req_major, req_minor, req_patch)
435                && (parsed.major, 0u64, 0u64) < (req_major + 1, 0, 0)
436        }
437        VersionReq::Approx { .. } => {
438            parsed_tuple >= (req_major, req_minor, req_patch)
439                && parsed_tuple < (req_major, req_minor + 1, 0)
440        }
441        VersionReq::Gte { .. } => parsed_tuple >= (req_major, req_minor, req_patch),
442        VersionReq::Lte { .. } => parsed_tuple <= (req_major, req_minor, req_patch),
443        VersionReq::Gt { .. } => parsed_tuple > (req_major, req_minor, req_patch),
444        VersionReq::Lt { .. } => parsed_tuple < (req_major, req_minor, req_patch),
445        VersionReq::Exact { .. } => parsed_tuple == (req_major, req_minor, req_patch),
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::JavaInfo;
453
454    // -----------------------------------------------------------------------
455    // Parsing
456    // -----------------------------------------------------------------------
457
458    #[test]
459    fn test_parse_full_spec() {
460        let spec = JavaSpec::parse("Eclipse Adoptium v^17.0.0 ax86_64").unwrap();
461        assert_eq!(spec.vendor.as_deref(), Some("Eclipse Adoptium"));
462        assert_eq!(
463            spec.version,
464            Some(VersionReq::Compatible {
465                major: 17,
466                minor: 0,
467                patch: 0
468            })
469        );
470        assert_eq!(spec.arch.as_deref(), Some("x86_64"));
471        assert_eq!(spec.raw, "Eclipse Adoptium v^17.0.0 ax86_64");
472    }
473
474    #[test]
475    fn test_parse_vendor_only() {
476        let spec = JavaSpec::parse("Azul Zulu").unwrap();
477        assert_eq!(spec.vendor.as_deref(), Some("Azul Zulu"));
478        assert!(spec.version.is_none());
479        assert!(spec.arch.is_none());
480    }
481
482    #[test]
483    fn test_parse_version_only() {
484        let spec = JavaSpec::parse("v^11.0.2").unwrap();
485        assert!(spec.vendor.is_none());
486        assert_eq!(
487            spec.version,
488            Some(VersionReq::Compatible {
489                major: 11,
490                minor: 0,
491                patch: 2
492            })
493        );
494        assert!(spec.arch.is_none());
495    }
496
497    #[test]
498    fn test_parse_exact_version() {
499        let spec = JavaSpec::parse("v17.0.1").unwrap();
500        assert_eq!(
501            spec.version,
502            Some(VersionReq::Exact {
503                major: 17,
504                minor: 0,
505                patch: 1
506            })
507        );
508    }
509
510    #[test]
511    fn test_parse_tilde_version() {
512        let spec = JavaSpec::parse("v~21.0.0").unwrap();
513        assert_eq!(
514            spec.version,
515            Some(VersionReq::Approx {
516                major: 21,
517                minor: 0,
518                patch: 0
519            })
520        );
521    }
522
523    #[test]
524    fn test_parse_gte_version() {
525        let spec = JavaSpec::parse("v>=8.0.0").unwrap();
526        assert_eq!(
527            spec.version,
528            Some(VersionReq::Gte {
529                major: 8,
530                minor: 0,
531                patch: 0
532            })
533        );
534    }
535
536    #[test]
537    fn test_parse_arch_only() {
538        let spec = JavaSpec::parse("aaarch64").unwrap();
539        assert!(spec.vendor.is_none());
540        assert!(spec.version.is_none());
541        assert_eq!(spec.arch.as_deref(), Some("aarch64"));
542    }
543
544    #[test]
545    fn test_parse_invalid_version() {
546        let spec = JavaSpec::parse("vabc").unwrap();
547        assert!(spec.version.is_none());
548    }
549
550    #[test]
551    fn test_parse_empty() {
552        let spec = JavaSpec::parse("").unwrap();
553        assert!(spec.vendor.is_none());
554        assert!(spec.version.is_none());
555        assert!(spec.arch.is_none());
556    }
557
558    #[test]
559    fn test_parse_arch_x86() {
560        let spec = JavaSpec::parse("ax86_64").unwrap();
561        assert_eq!(spec.arch.as_deref(), Some("x86_64"));
562    }
563
564    // -----------------------------------------------------------------------
565    // Matching
566    // -----------------------------------------------------------------------
567
568    fn make_info(name: &str, version: &str, arch: &str) -> JavaInfo {
569        JavaInfo {
570            name: name.to_string(),
571            vendor: name.to_string(),
572            version: version.to_string(),
573            parsed_version: crate::JavaVersion::parse(version),
574            architecture: arch.to_string(),
575            ..Default::default()
576        }
577    }
578
579    #[test]
580    fn test_match_vendor_substring() {
581        let spec = JavaSpec::parse("Adoptium v^17.0.0 ax86_64").unwrap();
582        let info = make_info("Eclipse Adoptium", "17.0.2", "x86_64");
583        assert!(spec.matches(&info));
584    }
585
586    #[test]
587    fn test_match_vendor_no_match() {
588        let spec = JavaSpec::parse("Azul").unwrap();
589        let info = make_info("Eclipse Adoptium", "17.0.2", "x86_64");
590        assert!(!spec.matches(&info));
591    }
592
593    #[test]
594    fn test_match_version_compatible() {
595        let spec = JavaSpec::parse("v^17.0.0").unwrap();
596        // 17.0.2 >= 17.0.0 AND < 18.0.0 → true
597        assert!(spec.matches(&make_info("Any", "17.0.2", "x86_64")));
598        // 18.0.0 >= 17.0.0 but NOT < 18.0.0 → false
599        assert!(!spec.matches(&make_info("Any", "18.0.0", "x86_64")));
600        // 11.0.0 < 17.0.0 → false
601        assert!(!spec.matches(&make_info("Any", "11.0.0", "x86_64")));
602    }
603
604    #[test]
605    fn test_match_version_exact() {
606        let spec = JavaSpec::parse("v17.0.2").unwrap();
607        assert!(spec.matches(&make_info("Any", "17.0.2", "x86_64")));
608        assert!(!spec.matches(&make_info("Any", "17.0.3", "x86_64")));
609    }
610
611    #[test]
612    fn test_match_version_approx() {
613        let spec = JavaSpec::parse("v~17.0.0").unwrap();
614        assert!(spec.matches(&make_info("Any", "17.0.2", "x86_64")));
615        assert!(!spec.matches(&make_info("Any", "17.1.0", "x86_64")));
616    }
617
618    #[test]
619    fn test_match_version_gte() {
620        let spec = JavaSpec::parse("v>=17.0.0").unwrap();
621        assert!(spec.matches(&make_info("Any", "17.0.0", "x86_64")));
622        assert!(spec.matches(&make_info("Any", "21.0.1", "x86_64")));
623        assert!(!spec.matches(&make_info("Any", "11.0.0", "x86_64")));
624    }
625
626    #[test]
627    fn test_match_arch_substring() {
628        let spec = JavaSpec::parse("aarch64").unwrap();
629        assert!(spec.matches(&make_info("Any", "17.0.0", "aarch64")));
630        assert!(spec.matches(&make_info("Any", "17.0.0", "ARM 64 aarch64")));
631        assert!(!spec.matches(&make_info("Any", "17.0.0", "x86_64")));
632    }
633
634    #[test]
635    fn test_match_all_criteria() {
636        let spec = JavaSpec::parse("Eclipse Adoptium v^17.0.0 ax86_64").unwrap();
637        let info = make_info("Eclipse Adoptium", "17.0.2", "x86_64");
638        assert!(spec.matches(&info));
639    }
640
641    #[test]
642    fn test_match_all_criteria_fails_vendor() {
643        let spec = JavaSpec::parse("Eclipse Adoptium v^17.0.0 ax86_64").unwrap();
644        let info = make_info("Azul Zulu", "17.0.2", "x86_64");
645        assert!(!spec.matches(&info));
646    }
647
648    #[test]
649    fn test_match_all_criteria_fails_arch() {
650        let spec = JavaSpec::parse("Eclipse Adoptium v^17.0.0 ax86_64").unwrap();
651        let info = make_info("Eclipse Adoptium", "17.0.2", "aarch64");
652        assert!(!spec.matches(&info));
653    }
654
655    #[test]
656    fn test_match_empty_spec() {
657        let spec = JavaSpec::parse("").unwrap();
658        assert!(spec.matches(&make_info("Anything", "17.0.0", "any")));
659    }
660
661    #[test]
662    fn test_match_no_parsed_version() {
663        let spec = JavaSpec::parse("v^17.0.0").unwrap();
664        let info = JavaInfo::default();
665        assert!(!spec.matches(&info));
666    }
667
668    #[test]
669    fn test_version_req_display() {
670        assert_eq!(
671            VersionReq::Compatible {
672                major: 17,
673                minor: 0,
674                patch: 0
675            }
676            .to_string(),
677            "^17.0.0"
678        );
679        assert_eq!(
680            VersionReq::Exact {
681                major: 11,
682                minor: 0,
683                patch: 2
684            }
685            .to_string(),
686            "11.0.2"
687        );
688        assert_eq!(
689            VersionReq::Gte {
690                major: 8,
691                minor: 0,
692                patch: 0
693            }
694            .to_string(),
695            ">=8.0.0"
696        );
697    }
698}