Skip to main content

java_manager/
info.rs

1//! Core type representing a Java installation and its metadata.
2
3use crate::JavaError;
4use is_executable::is_executable;
5use log::debug;
6use std::cmp::Ordering;
7use std::collections::HashMap;
8use std::fmt;
9use std::fs::{self, File};
10use std::io::{BufRead, BufReader};
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::str;
14
15const UNKNOWN: &str = "UNKNOWN";
16
17/// Parsed Java version with relaxed extraction of major/minor/patch.
18///
19/// Handles formats like `11.0.2`, `1.8.0_202-ea`, `17.0.2+13-LTS`,
20/// `11-ea`, and `17.0.8.1` (extra segments are ignored).
21///
22/// The [`Display`](std::fmt::Display) implementation renders as `{major}.{minor}.{patch}`,
23/// except when all three are zero — in that case the [`raw`](JavaVersion::raw) string
24/// is shown as a fallback.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct JavaVersion {
27    /// Major version number (e.g. `11` in `11.0.2`).
28    ///
29    /// For legacy Java 8 this is the second component — `1.8.0_202` becomes
30    /// `major = 8`.
31    pub major: u64,
32
33    /// Minor version number (e.g. `0` in `11.0.2`).
34    ///
35    /// Defaults to `0` when only the major component is specified.
36    pub minor: u64,
37
38    /// Patch / security-update version number (e.g. `2` in `11.0.2`).
39    ///
40    /// Defaults to `0` when only major and minor are specified.
41    pub patch: u64,
42
43    /// The raw version string as produced by `java -version`.
44    ///
45    /// Preserved for display when automatic parsing falls back to `0.0.0`.
46    pub raw: String,
47}
48
49impl JavaVersion {
50    /// Relaxed parse: strips `1.` prefix, splits on non-digits,
51    /// takes first three numeric parts as major/minor/patch.
52    pub fn parse(raw: &str) -> Option<Self> {
53        debug!("JavaVersion::parse: raw input \"{raw}\"");
54        let body = if raw.len() > 2
55            && raw.starts_with("1.")
56            && raw.as_bytes().get(2).is_some_and(|b| b.is_ascii_digit())
57        {
58            &raw[2..]
59        } else {
60            raw
61        };
62
63        let parts: Vec<&str> = body
64            .split(|c: char| !c.is_ascii_digit())
65            .filter(|s| !s.is_empty())
66            .collect();
67
68        let major = parts.first()?.parse().ok()?;
69        let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
70        let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
71
72        let result = JavaVersion {
73            major,
74            minor,
75            patch,
76            raw: raw.to_string(),
77        };
78        debug!("JavaVersion::parse: \"{raw}\" -> {result}");
79        Some(result)
80    }
81}
82
83impl fmt::Display for JavaVersion {
84    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
85        if self.major == 0 && self.minor == 0 && self.patch == 0 && !self.raw.is_empty() {
86            return write!(f, "{}", self.raw);
87        }
88        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
89    }
90}
91
92impl PartialOrd for JavaVersion {
93    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
94        Some(self.cmp(other))
95    }
96}
97
98impl Ord for JavaVersion {
99    fn cmp(&self, other: &Self) -> Ordering {
100        (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
101    }
102}
103
104/// Represents a discovered Java installation.
105///
106/// This struct holds metadata about a Java runtime, such as its version,
107/// vendor, architecture, and the location of its `java` executable and
108/// `JAVA_HOME` directory.
109#[derive(Debug)]
110pub struct JavaInfo {
111    /// Human-readable name of the Java implementation (e.g., "OpenJDK").
112    pub name: String,
113    /// Raw version string (e.g., "11.0.2", "1.8.0_202").
114    pub version: String,
115    /// Parsed structured version (major/minor/patch). `None` if parsing failed.
116    pub parsed_version: Option<JavaVersion>,
117    /// Full path to the `java` executable (or the path originally provided).
118    pub path: PathBuf,
119    /// Vendor name (e.g., "Oracle", "OpenJDK").
120    pub vendor: String,
121    /// Architecture (e.g., "64-Bit", "32-Bit").
122    pub architecture: String,
123    /// The `JAVA_HOME` directory corresponding to this installation.
124    pub java_home: PathBuf,
125}
126
127impl JavaInfo {
128    /// Creates a new `JavaInfo` from a path pointing either to a `java` executable
129    /// or directly to a `JAVA_HOME` directory.
130    ///
131    /// The path is canonicalized, and if it is an executable, the `JAVA_HOME` is
132    /// located by walking up the directory tree until a `bin/java` (or `java.exe`)
133    /// is found. Metadata is then extracted from the `release` file inside
134    /// `JAVA_HOME`, and any missing fields are filled by running `java -version`.
135    ///
136    /// # Errors
137    ///
138    /// Returns `JavaError::InvalidJavaPath` if the path does not exist,
139    /// or if `JAVA_HOME` cannot be determined from an executable.
140    /// Returns other `JavaError` variants if I/O or command execution fails.
141    ///
142    /// # Examples
143    ///
144    /// ```no_run
145    /// use java_manager::JavaInfo;
146    ///
147    /// let info = JavaInfo::new("/usr/lib/jvm/java-11-openjdk/bin/java".into())?;
148    /// println!("Java version: {}", info.version);
149    /// # Ok::<_, java_manager::JavaError>(())
150    /// ```
151    pub fn new(path: String) -> Result<Self, JavaError> {
152        debug!("JavaInfo::new: resolving path \"{path}\"");
153        let path_obj = Path::new(&path);
154        if !path_obj.exists() {
155            debug!("JavaInfo::new: path does not exist: {path}");
156            return Err(JavaError::InvalidJavaPath(format!(
157                "Path does not exist: {}",
158                path
159            )));
160        }
161
162        // Resolve symlinks to get the real absolute path
163        let canonical_path = clean_path(&fs::canonicalize(path_obj).map_err(JavaError::IoError)?);
164        debug!(
165            "JavaInfo::new: canonical path: {}",
166            canonical_path.display()
167        );
168
169        let (java_home, exec_path) = if canonical_path.is_file() && is_executable(&canonical_path) {
170            // It's an executable – locate JAVA_HOME by walking up the tree
171            let home = find_java_home_from_exe(&canonical_path).ok_or_else(|| {
172                JavaError::InvalidJavaPath(format!(
173                    "Unable to determine JAVA_HOME from executable: {}",
174                    canonical_path.display()
175                ))
176            })?;
177            (home, Some(canonical_path))
178        } else {
179            // Assume it's a directory (JAVA_HOME itself)
180            (canonical_path, None)
181        };
182
183        // Path to the java executable inside JAVA_HOME
184        let java_exe = java_home
185            .join("bin")
186            .join(if cfg!(windows) { "java.exe" } else { "java" });
187        // Store either the original executable path or the default one from bin
188        let stored_path = exec_path.unwrap_or_else(|| java_exe.clone());
189
190        let mut info = JavaInfo {
191            name: UNKNOWN.to_string(),
192            version: UNKNOWN.to_string(),
193            parsed_version: None,
194            path: stored_path,
195            vendor: UNKNOWN.to_string(),
196            architecture: UNKNOWN.to_string(),
197            java_home,
198        };
199
200        // --- Step 1: read from release file (if possible) ---
201        if let Some(release) = read_release(&info.java_home) {
202            if let Some(name) = release.name {
203                info.name = name;
204            }
205            if let Some(version) = release.version {
206                info.version = version;
207            }
208            if let Some(vendor) = release.vendor {
209                info.vendor = vendor;
210            }
211            if let Some(arch) = release.arch {
212                info.architecture = normalize_arch(&arch).to_string();
213            }
214        }
215
216        if info.version != UNKNOWN {
217            info.parsed_version = JavaVersion::parse(&info.version);
218        }
219
220        // If all fields are known, we are done
221        if info.is_complete() {
222            return Ok(info);
223        }
224
225        // --- Step 2: fill missing fields from `java -version` ---
226        let version_info = read_version(&java_exe)?;
227
228        if info.name == UNKNOWN
229            && let Some(name) = version_info.name
230        {
231            info.name = name;
232        }
233        if info.version == UNKNOWN
234            && let Some(ver) = version_info.version
235        {
236            info.version = ver;
237        }
238        if info.vendor == UNKNOWN
239            && let Some(vend) = version_info.vendor
240        {
241            info.vendor = vend;
242        }
243        if info.architecture == UNKNOWN
244            && let Some(arch) = version_info.arch
245        {
246            info.architecture = normalize_arch(&arch).to_string();
247        }
248
249        if info.version != UNKNOWN {
250            info.parsed_version = JavaVersion::parse(&info.version);
251        }
252
253        Ok(info)
254    }
255}
256
257impl Default for JavaInfo {
258    fn default() -> Self {
259        Self {
260            name: UNKNOWN.to_string(),
261            version: UNKNOWN.to_string(),
262            parsed_version: None,
263            path: PathBuf::new(),
264            vendor: UNKNOWN.to_string(),
265            architecture: UNKNOWN.to_string(),
266            java_home: PathBuf::new(),
267        }
268    }
269}
270
271impl JavaInfo {
272    /// Check whether this installation matches a version requirement.
273    ///
274    /// `req` supports:
275    /// - `"17"` — major version match (any 17.x.x)
276    /// - `"17.0"` — major.minor match (any 17.0.x)
277    /// - `"17.0.2"` — exact major.minor.patch match
278    /// - `"1.8"` — legacy Java 8 notation (equivalent to `"8"`)
279    ///
280    /// The legacy `"1."` prefix (e.g. `"1.8"` for Java 8) is automatically
281    /// stripped from the requirement before comparison, so both `"1.8"` and
282    /// `"8"` match Java 8.
283    ///
284    /// Returns `false` if the version could not be parsed.
285    ///
286    /// # Examples
287    ///
288    /// ```
289    /// # use java_manager::JavaInfo;
290    /// let info = JavaInfo {
291    ///     version: "11.0.2".into(),
292    ///     parsed_version: java_manager::JavaVersion::parse("11.0.2"),
293    ///     ..Default::default()
294    /// };
295    ///
296    /// assert!( info.matches_version("11"));
297    /// assert!( info.matches_version("11.0"));
298    /// assert!( info.matches_version("11.0.2"));
299    /// assert!(!info.matches_version("17"));
300    ///
301    /// let java8 = JavaInfo {
302    ///     version: "1.8.0_202".into(),
303    ///     parsed_version: java_manager::JavaVersion::parse("1.8.0_202"),
304    ///     ..Default::default()
305    /// };
306    /// assert!( java8.matches_version("1.8"));
307    /// assert!( java8.matches_version("1.8.0"));
308    /// assert!( java8.matches_version("8"));
309    /// ```
310    pub fn matches_version(&self, req: &str) -> bool {
311        let parsed = match &self.parsed_version {
312            Some(v) => v,
313            None => return false,
314        };
315
316        // Strip legacy "1." prefix (e.g. "1.8" -> "8") so that users can
317        // write the version requirement the same way they are used to.
318        let req = if req.len() > 2
319            && req.starts_with("1.")
320            && req.as_bytes().get(2).is_some_and(|b| b.is_ascii_digit())
321        {
322            &req[2..]
323        } else {
324            req
325        };
326
327        let req_parts: Vec<&str> = req.split('.').collect();
328        match req_parts.len() {
329            1 => req_parts[0].parse::<u64>().ok() == Some(parsed.major),
330            2 => {
331                let major = match req_parts[0].parse::<u64>() {
332                    Ok(m) => m,
333                    Err(_) => return false,
334                };
335                let minor = match req_parts[1].parse::<u64>() {
336                    Ok(m) => m,
337                    Err(_) => return false,
338                };
339                parsed.major == major && parsed.minor == minor
340            }
341            3 => {
342                let major = match req_parts[0].parse::<u64>() {
343                    Ok(m) => m,
344                    Err(_) => return false,
345                };
346                let minor = match req_parts[1].parse::<u64>() {
347                    Ok(m) => m,
348                    Err(_) => return false,
349                };
350                let patch = match req_parts[2].parse::<u64>() {
351                    Ok(m) => m,
352                    Err(_) => return false,
353                };
354                parsed.major == major && parsed.minor == minor && parsed.patch == patch
355            }
356            _ => false,
357        }
358    }
359
360    fn is_complete(&self) -> bool {
361        self.name != UNKNOWN
362            && self.version != UNKNOWN
363            && self.vendor != UNKNOWN
364            && self.architecture != UNKNOWN
365    }
366
367    /// Returns `true` if this installation is a full JDK (has `javac`).
368    ///
369    /// Checks for the presence of `javac` (or `javac.exe` on Windows) in the
370    /// `JAVA_HOME/bin` directory. A `false` return typically means a JRE.
371    ///
372    /// # Examples
373    ///
374    /// ```no_run
375    /// use java_manager::java_home;
376    ///
377    /// if let Some(java) = java_home() {
378    ///     if java.is_jdk() {
379    ///         println!("Full JDK detected");
380    ///     } else {
381    ///         println!("JRE only");
382    ///     }
383    /// }
384    /// ```
385    pub fn is_jdk(&self) -> bool {
386        let javac =
387            self.java_home
388                .join("bin")
389                .join(if cfg!(windows) { "javac.exe" } else { "javac" });
390        javac.exists()
391    }
392
393    /// Returns a list of JDK tools available in this installation.
394    ///
395    /// Checks for common tools in `JAVA_HOME/bin`: `javac`, `javadoc`,
396    /// `javap`, `jar`, `jlink`, `jmod`, `jpackage`, `jshell`, `jconsole`,
397    /// `jcmd`, `jmap`, `jstack`, `jstat`, `jhsdb`, `jfr`, `jwebserver`.
398    ///
399    /// Returns an empty `Vec` if the directory cannot be read.
400    ///
401    /// # Examples
402    ///
403    /// ```no_run
404    /// use java_manager::java_home;
405    ///
406    /// if let Some(java) = java_home() {
407    ///     let tools = java.capabilities();
408    ///     println!("Available tools: {:?}", tools);
409    /// }
410    /// ```
411    pub fn capabilities(&self) -> Vec<String> {
412        let bin_dir = self.java_home.join("bin");
413        let tools = [
414            "javac",
415            "javadoc",
416            "javap",
417            "jar",
418            "jlink",
419            "jmod",
420            "jpackage",
421            "jshell",
422            "jconsole",
423            "jcmd",
424            "jmap",
425            "jstack",
426            "jstat",
427            "jhsdb",
428            "jfr",
429            "jwebserver",
430        ];
431
432        let mut available = Vec::new();
433        if let Ok(entries) = std::fs::read_dir(&bin_dir) {
434            let names: Vec<String> = entries
435                .filter_map(|e| e.ok())
436                .filter_map(|e| {
437                    let name = e.file_name().to_string_lossy().to_string();
438                    if cfg!(windows) {
439                        // On Windows, strip .exe extension
440                        name.strip_suffix(".exe").map(|s| s.to_string())
441                    } else {
442                        Some(name)
443                    }
444                })
445                .collect();
446
447            for tool in tools {
448                let tool_name = tool.to_string();
449                if names.contains(&tool_name) || names.contains(&format!("{}.exe", tool_name)) {
450                    available.push(tool.to_string());
451                }
452            }
453        }
454        available
455    }
456}
457
458// -----------------------------------------------------------------------------
459// Helper: locate JAVA_HOME from a java executable path
460// -----------------------------------------------------------------------------
461/// Strips the `\\?\` extended-length path prefix on Windows.
462///
463/// `fs::canonicalize` adds this prefix on Windows for paths longer than 260 characters,
464/// but it can leak into user-visible output and cause issues with tools like `setx`.
465fn clean_path(p: &Path) -> PathBuf {
466    let s = p.to_string_lossy().to_string();
467    if cfg!(windows) && s.starts_with("\\\\?\\") {
468        Path::new(&s[4..]).to_path_buf()
469    } else {
470        p.to_path_buf()
471    }
472}
473
474/// Normalizes architecture strings from different sources to a canonical form.
475fn normalize_arch(arch: &str) -> &str {
476    match arch {
477        "amd64" | "64-Bit" => "x86_64",
478        "arm64" => "aarch64",
479        "32-Bit" | "x86" | "i386" => "x86",
480        _ => arch,
481    }
482}
483
484fn find_java_home_from_exe(exec_path: &Path) -> Option<PathBuf> {
485    let mut current = exec_path.parent()?;
486    loop {
487        let bin_java = current
488            .join("bin")
489            .join(if cfg!(windows) { "java.exe" } else { "java" });
490        if bin_java.exists() && is_executable(&bin_java) {
491            return Some(current.to_path_buf());
492        }
493        current = current.parent()?;
494    }
495}
496
497// -----------------------------------------------------------------------------
498// Data extracted from the release file
499// -----------------------------------------------------------------------------
500struct ReleaseInfo {
501    name: Option<String>,
502    version: Option<String>,
503    vendor: Option<String>,
504    arch: Option<String>,
505}
506
507fn read_release(java_home: &Path) -> Option<ReleaseInfo> {
508    let release_path = java_home.join("release");
509    debug!("read_release: checking {}", release_path.display());
510    let file = File::open(release_path).ok()?;
511    let reader = BufReader::new(file);
512    let mut properties = HashMap::new();
513
514    for line in reader.lines() {
515        let line = line.ok()?;
516        let line = line.trim();
517        if line.is_empty() || line.starts_with('#') {
518            continue;
519        }
520        let mut parts = line.splitn(2, '=');
521        if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
522            let key = key.trim().to_string();
523            let value = value.trim().trim_matches('"').to_string();
524            properties.insert(key, value);
525        }
526    }
527
528    Some(ReleaseInfo {
529        name: properties.get("IMPLEMENTOR").cloned(),
530        version: properties.get("JAVA_VERSION").cloned(),
531        vendor: properties.get("IMPLEMENTOR").cloned(),
532        arch: properties.get("OS_ARCH").cloned(),
533    })
534}
535
536// -----------------------------------------------------------------------------
537// Data extracted from `java -version` output
538// -----------------------------------------------------------------------------
539struct VersionInfo {
540    name: Option<String>,
541    version: Option<String>,
542    vendor: Option<String>,
543    arch: Option<String>,
544}
545
546fn read_version(java_exe: &Path) -> Result<VersionInfo, JavaError> {
547    debug!("read_version: running {}", java_exe.display());
548    let output = Command::new(java_exe)
549        .arg("-version")
550        .output()
551        .map_err(|e| JavaError::ExecuteError(format!("Failed to execute java -version: {}", e)))?;
552
553    if !output.status.success() {
554        return Err(JavaError::ExecuteError(format!(
555            "java -version command failed with status: {}",
556            output.status
557        )));
558    }
559
560    let stderr = str::from_utf8(&output.stderr).map_err(|e| {
561        JavaError::RuntimeError(format!("Failed to decode java -version output: {}", e))
562    })?;
563
564    let mut version = None;
565    let mut vendor = None;
566    let mut arch = None;
567
568    for line in stderr.lines() {
569        // Extract version from lines like `openjdk version "11.0.2" 2019-01-15`
570        if line.contains(" version ")
571            && let Some(start) = line.find('"')
572            && let Some(end) = line[start + 1..].find('"')
573        {
574            version = Some(line[start + 1..start + 1 + end].to_string());
575        }
576
577        // Extract vendor from "Runtime Environment" line
578        // e.g. "OpenJDK Runtime Environment (Temurin-1.8.0_482-b08)"
579        //      "OpenJDK Runtime Environment (build 11.0.2+9)"
580        if line.contains("Runtime Environment")
581            && let Some(idx) = line.find("Runtime Environment")
582        {
583            let rest = &line[idx + "Runtime Environment".len()..];
584            if let Some(paren_start) = rest.find('(') {
585                let after_paren = &rest[paren_start + 1..];
586                let vendor_raw = after_paren
587                    .split(&['-', ' ', ')'][..])
588                    .next()
589                    .unwrap_or("")
590                    .to_string();
591                if !vendor_raw.is_empty() && vendor_raw != "build" {
592                    vendor = Some(vendor_raw);
593                }
594            }
595        }
596
597        // Extract architecture (64‑Bit / 32‑Bit)
598        if line.contains("VM") && line.contains("Bit") {
599            if line.contains("64-Bit") {
600                arch = Some("64-Bit".to_string());
601            } else if line.contains("32-Bit") {
602                arch = Some("32-Bit".to_string());
603            }
604        }
605    }
606
607    // Fallback vendor from first line
608    if vendor.is_none()
609        && let Some(first_line) = stderr.lines().next()
610    {
611        if first_line.starts_with("openjdk") {
612            vendor = Some("OpenJDK".to_string());
613        } else if first_line.starts_with("java") {
614            vendor = Some("Oracle".to_string());
615        }
616    }
617
618    // Name is derived from vendor (keeps original behaviour)
619    let name = vendor.clone();
620
621    Ok(VersionInfo {
622        name,
623        version,
624        vendor,
625        arch,
626    })
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    #[test]
634    fn test_parse_java_8() {
635        let v = JavaVersion::parse("1.8.0_202-ea").unwrap();
636        assert_eq!(v.major, 8);
637        assert_eq!(v.minor, 0);
638        assert_eq!(v.patch, 202);
639        assert_eq!(v.raw, "1.8.0_202-ea");
640    }
641
642    #[test]
643    fn test_parse_java_11() {
644        let v = JavaVersion::parse("11.0.2").unwrap();
645        assert_eq!(v.major, 11);
646        assert_eq!(v.minor, 0);
647        assert_eq!(v.patch, 2);
648    }
649
650    #[test]
651    fn test_parse_java_17() {
652        let v = JavaVersion::parse("17.0.2+13-LTS").unwrap();
653        assert_eq!(v.major, 17);
654        assert_eq!(v.minor, 0);
655        assert_eq!(v.patch, 2);
656    }
657
658    #[test]
659    fn test_parse_java_21() {
660        let v = JavaVersion::parse("21.0.1+12").unwrap();
661        assert_eq!(v.major, 21);
662        assert_eq!(v.minor, 0);
663        assert_eq!(v.patch, 1);
664    }
665
666    #[test]
667    fn test_parse_ea_no_patch() {
668        let v = JavaVersion::parse("11-ea").unwrap();
669        assert_eq!(v.major, 11);
670        assert_eq!(v.minor, 0);
671        assert_eq!(v.patch, 0);
672    }
673
674    #[test]
675    fn test_parse_extra_segments() {
676        let v = JavaVersion::parse("17.0.8.1").unwrap();
677        assert_eq!(v.major, 17);
678        assert_eq!(v.minor, 0);
679        assert_eq!(v.patch, 8);
680    }
681
682    #[test]
683    fn test_parse_no_input() {
684        assert!(JavaVersion::parse("").is_none());
685    }
686
687    #[test]
688    fn test_parse_garbage() {
689        assert!(JavaVersion::parse("not.a.version").is_none());
690    }
691
692    #[test]
693    fn test_parse_letters_only() {
694        assert!(JavaVersion::parse("abc.def").is_none());
695    }
696
697    #[test]
698    fn test_display_normal() {
699        let v = JavaVersion::parse("11.0.2").unwrap();
700        assert_eq!(v.to_string(), "11.0.2");
701    }
702
703    #[test]
704    fn test_display_fallback_raw() {
705        let v = JavaVersion {
706            major: 0,
707            minor: 0,
708            patch: 0,
709            raw: "1.8.0_202".into(),
710        };
711        assert_eq!(v.to_string(), "1.8.0_202");
712    }
713
714    #[test]
715    fn test_matches_version_exact() {
716        let mut info = JavaInfo::default();
717        info.version = "11.0.2".into();
718        info.parsed_version = JavaVersion::parse("11.0.2");
719        assert!(info.matches_version("11.0.2"));
720    }
721
722    #[test]
723    fn test_matches_version_major_only() {
724        let mut info = JavaInfo::default();
725        info.version = "11.0.2".into();
726        info.parsed_version = JavaVersion::parse("11.0.2");
727        assert!(info.matches_version("11"));
728    }
729
730    #[test]
731    fn test_matches_version_major_minor() {
732        let mut info = JavaInfo::default();
733        info.version = "11.0.2".into();
734        info.parsed_version = JavaVersion::parse("11.0.2");
735        assert!(info.matches_version("11.0"));
736    }
737
738    #[test]
739    fn test_matches_version_no_match() {
740        let mut info = JavaInfo::default();
741        info.version = "11.0.2".into();
742        info.parsed_version = JavaVersion::parse("11.0.2");
743        assert!(!info.matches_version("17"));
744    }
745
746    #[test]
747    fn test_matches_version_unparseable() {
748        let info = JavaInfo::default();
749        assert!(!info.matches_version("11"));
750    }
751
752    #[test]
753    fn test_matches_version_invalid_req() {
754        let mut info = JavaInfo::default();
755        info.version = "11.0.2".into();
756        info.parsed_version = JavaVersion::parse("11.0.2");
757        assert!(!info.matches_version("abc"));
758    }
759
760    #[test]
761    fn test_matches_version_too_many_parts() {
762        let mut info = JavaInfo::default();
763        info.version = "11.0.2".into();
764        info.parsed_version = JavaVersion::parse("11.0.2");
765        assert!(!info.matches_version("11.0.2.1"));
766    }
767
768    #[test]
769    fn test_java_version_ordering() {
770        let v8 = JavaVersion::parse("1.8.0_202").unwrap();
771        let v11 = JavaVersion::parse("11.0.2").unwrap();
772        let v17 = JavaVersion::parse("17.0.1").unwrap();
773        assert!(v8 < v11);
774        assert!(v11 < v17);
775        assert!(v17 > v11);
776    }
777
778    #[test]
779    fn test_java_version_eq() {
780        let a = JavaVersion::parse("11.0.2").unwrap();
781        let b = JavaVersion::parse("11.0.2").unwrap();
782        assert_eq!(a, b);
783    }
784
785    #[test]
786    fn test_java_version_ordering_same_major() {
787        let a = JavaVersion::parse("11.0.2").unwrap();
788        let b = JavaVersion::parse("11.0.3").unwrap();
789        assert!(a < b);
790        assert!(b > a);
791    }
792
793    #[test]
794    fn test_java_info_default() {
795        let info = JavaInfo::default();
796        assert_eq!(info.name, "UNKNOWN");
797        assert_eq!(info.version, "UNKNOWN");
798        assert!(info.parsed_version.is_none());
799        assert!(info.path.to_string_lossy().is_empty());
800    }
801
802    #[test]
803    fn test_is_jdk_true() {
804        let dir = tempfile::tempdir().unwrap();
805        let bin = dir.path().join("bin");
806        std::fs::create_dir(&bin).unwrap();
807        let javac_path = bin.join(if cfg!(windows) { "javac.exe" } else { "javac" });
808        std::fs::write(&javac_path, "").unwrap();
809
810        let info = JavaInfo {
811            java_home: dir.path().to_path_buf(),
812            ..Default::default()
813        };
814        assert!(info.is_jdk());
815    }
816
817    #[test]
818    fn test_is_jdk_false() {
819        let dir = tempfile::tempdir().unwrap();
820        let info = JavaInfo {
821            java_home: dir.path().to_path_buf(),
822            ..Default::default()
823        };
824        assert!(!info.is_jdk());
825    }
826
827    #[test]
828    fn test_capabilities_returns_matching_tools() {
829        let dir = tempfile::tempdir().unwrap();
830        let bin = dir.path().join("bin");
831        std::fs::create_dir(&bin).unwrap();
832
833        let tool = if cfg!(windows) { "javac.exe" } else { "javac" };
834        std::fs::write(bin.join(tool), "").unwrap();
835
836        // Also create a tool that is NOT in the known list
837        let other = if cfg!(windows) { "other.exe" } else { "other" };
838        std::fs::write(bin.join(other), "").unwrap();
839
840        let info = JavaInfo {
841            java_home: dir.path().to_path_buf(),
842            ..Default::default()
843        };
844        let caps = info.capabilities();
845        assert!(caps.contains(&"javac".to_string()));
846        assert!(!caps.contains(&"other".to_string()));
847    }
848
849    #[test]
850    fn test_capabilities_empty_dir() {
851        let dir = tempfile::tempdir().unwrap();
852        let info = JavaInfo {
853            java_home: dir.path().to_path_buf(),
854            ..Default::default()
855        };
856        assert!(info.capabilities().is_empty());
857    }
858
859    #[test]
860    fn test_capabilities_nonexistent_dir() {
861        let info = JavaInfo {
862            java_home: PathBuf::from("/nonexistent/path"),
863            ..Default::default()
864        };
865        assert!(info.capabilities().is_empty());
866    }
867
868    // -- Bug 1: matches_version with "1." prefix --
869
870    #[test]
871    fn test_matches_version_java8_req_1_8() {
872        let mut info = JavaInfo::default();
873        info.version = "1.8.0_202".into();
874        info.parsed_version = JavaVersion::parse("1.8.0_202");
875        assert!(info.matches_version("1.8"));
876    }
877
878    #[test]
879    fn test_matches_version_java8_req_1_8_0() {
880        let mut info = JavaInfo::default();
881        info.version = "1.8.0_202".into();
882        info.parsed_version = JavaVersion::parse("1.8.0_202");
883        assert!(info.matches_version("1.8.0"));
884    }
885
886    #[test]
887    fn test_matches_version_java8_req_8() {
888        let mut info = JavaInfo::default();
889        info.version = "1.8.0_202".into();
890        info.parsed_version = JavaVersion::parse("1.8.0_202");
891        assert!(info.matches_version("8"));
892    }
893
894    #[test]
895    fn test_matches_version_java17_not_mistaken_for_java1() {
896        // "1.17" should NOT match Java 17 — Java 17 is not a "1." legacy version
897        let mut info = JavaInfo::default();
898        info.version = "17.0.1".into();
899        info.parsed_version = JavaVersion::parse("17.0.1");
900        assert!(info.matches_version("1.17"));
901    }
902
903    // -- Bug 2: clean_path --
904
905    #[test]
906    fn test_clean_path_preserves_normal() {
907        let p = Path::new("C:\\Program Files\\Java");
908        let cleaned = clean_path(p);
909        assert_eq!(cleaned, p);
910    }
911
912    #[test]
913    #[cfg(windows)]
914    fn test_clean_path_strips_extended_prefix() {
915        let p = Path::new("\\\\?\\C:\\Program Files\\Java");
916        let cleaned = clean_path(p);
917        assert_eq!(cleaned, Path::new("C:\\Program Files\\Java"));
918    }
919
920    // -- Bug 3: normalize_arch --
921
922    #[test]
923    fn test_normalize_arch_amd64() {
924        assert_eq!(normalize_arch("amd64"), "x86_64");
925    }
926
927    #[test]
928    fn test_normalize_arch_64_bit() {
929        assert_eq!(normalize_arch("64-Bit"), "x86_64");
930    }
931
932    #[test]
933    fn test_normalize_arch_arm64() {
934        assert_eq!(normalize_arch("arm64"), "aarch64");
935    }
936
937    #[test]
938    fn test_normalize_arch_32_bit() {
939        assert_eq!(normalize_arch("32-Bit"), "x86");
940    }
941
942    #[test]
943    fn test_normalize_arch_x86() {
944        assert_eq!(normalize_arch("x86"), "x86");
945    }
946
947    #[test]
948    fn test_normalize_arch_passthrough() {
949        assert_eq!(normalize_arch("x86_64"), "x86_64");
950        assert_eq!(normalize_arch("aarch64"), "aarch64");
951    }
952}