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 = 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 = arch;
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 = arch;
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    ///
279    /// Returns `false` if the version could not be parsed.
280    ///
281    /// # Examples
282    ///
283    /// ```
284    /// # use java_manager::JavaInfo;
285    /// let info = JavaInfo {
286    ///     version: "11.0.2".into(),
287    ///     parsed_version: java_manager::JavaVersion::parse("11.0.2"),
288    ///     ..Default::default()
289    /// };
290    ///
291    /// assert!( info.matches_version("11"));
292    /// assert!( info.matches_version("11.0"));
293    /// assert!( info.matches_version("11.0.2"));
294    /// assert!(!info.matches_version("17"));
295    /// ```
296    pub fn matches_version(&self, req: &str) -> bool {
297        let parsed = match &self.parsed_version {
298            Some(v) => v,
299            None => return false,
300        };
301
302        let req_parts: Vec<&str> = req.split('.').collect();
303        match req_parts.len() {
304            1 => req_parts[0].parse::<u64>().ok() == Some(parsed.major),
305            2 => {
306                let major = match req_parts[0].parse::<u64>() {
307                    Ok(m) => m,
308                    Err(_) => return false,
309                };
310                let minor = match req_parts[1].parse::<u64>() {
311                    Ok(m) => m,
312                    Err(_) => return false,
313                };
314                parsed.major == major && parsed.minor == minor
315            }
316            3 => {
317                let major = match req_parts[0].parse::<u64>() {
318                    Ok(m) => m,
319                    Err(_) => return false,
320                };
321                let minor = match req_parts[1].parse::<u64>() {
322                    Ok(m) => m,
323                    Err(_) => return false,
324                };
325                let patch = match req_parts[2].parse::<u64>() {
326                    Ok(m) => m,
327                    Err(_) => return false,
328                };
329                parsed.major == major && parsed.minor == minor && parsed.patch == patch
330            }
331            _ => false,
332        }
333    }
334
335    fn is_complete(&self) -> bool {
336        self.name != UNKNOWN
337            && self.version != UNKNOWN
338            && self.vendor != UNKNOWN
339            && self.architecture != UNKNOWN
340    }
341
342    /// Returns `true` if this installation is a full JDK (has `javac`).
343    ///
344    /// Checks for the presence of `javac` (or `javac.exe` on Windows) in the
345    /// `JAVA_HOME/bin` directory. A `false` return typically means a JRE.
346    ///
347    /// # Examples
348    ///
349    /// ```no_run
350    /// use java_manager::java_home;
351    ///
352    /// if let Some(java) = java_home() {
353    ///     if java.is_jdk() {
354    ///         println!("Full JDK detected");
355    ///     } else {
356    ///         println!("JRE only");
357    ///     }
358    /// }
359    /// ```
360    pub fn is_jdk(&self) -> bool {
361        let javac =
362            self.java_home
363                .join("bin")
364                .join(if cfg!(windows) { "javac.exe" } else { "javac" });
365        javac.exists()
366    }
367
368    /// Returns a list of JDK tools available in this installation.
369    ///
370    /// Checks for common tools in `JAVA_HOME/bin`: `javac`, `javadoc`,
371    /// `javap`, `jar`, `jlink`, `jmod`, `jpackage`, `jshell`, `jconsole`,
372    /// `jcmd`, `jmap`, `jstack`, `jstat`, `jhsdb`, `jfr`, `jwebserver`.
373    ///
374    /// Returns an empty `Vec` if the directory cannot be read.
375    ///
376    /// # Examples
377    ///
378    /// ```no_run
379    /// use java_manager::java_home;
380    ///
381    /// if let Some(java) = java_home() {
382    ///     let tools = java.capabilities();
383    ///     println!("Available tools: {:?}", tools);
384    /// }
385    /// ```
386    pub fn capabilities(&self) -> Vec<String> {
387        let bin_dir = self.java_home.join("bin");
388        let tools = [
389            "javac",
390            "javadoc",
391            "javap",
392            "jar",
393            "jlink",
394            "jmod",
395            "jpackage",
396            "jshell",
397            "jconsole",
398            "jcmd",
399            "jmap",
400            "jstack",
401            "jstat",
402            "jhsdb",
403            "jfr",
404            "jwebserver",
405        ];
406
407        let mut available = Vec::new();
408        if let Ok(entries) = std::fs::read_dir(&bin_dir) {
409            let names: Vec<String> = entries
410                .filter_map(|e| e.ok())
411                .filter_map(|e| {
412                    let name = e.file_name().to_string_lossy().to_string();
413                    if cfg!(windows) {
414                        // On Windows, strip .exe extension
415                        name.strip_suffix(".exe").map(|s| s.to_string())
416                    } else {
417                        Some(name)
418                    }
419                })
420                .collect();
421
422            for tool in tools {
423                let tool_name = tool.to_string();
424                if names.contains(&tool_name) || names.contains(&format!("{}.exe", tool_name)) {
425                    available.push(tool.to_string());
426                }
427            }
428        }
429        available
430    }
431}
432
433// -----------------------------------------------------------------------------
434// Helper: locate JAVA_HOME from a java executable path
435// -----------------------------------------------------------------------------
436fn find_java_home_from_exe(exec_path: &Path) -> Option<PathBuf> {
437    let mut current = exec_path.parent()?;
438    loop {
439        let bin_java = current
440            .join("bin")
441            .join(if cfg!(windows) { "java.exe" } else { "java" });
442        if bin_java.exists() && is_executable(&bin_java) {
443            return Some(current.to_path_buf());
444        }
445        current = current.parent()?;
446    }
447}
448
449// -----------------------------------------------------------------------------
450// Data extracted from the release file
451// -----------------------------------------------------------------------------
452struct ReleaseInfo {
453    name: Option<String>,
454    version: Option<String>,
455    vendor: Option<String>,
456    arch: Option<String>,
457}
458
459fn read_release(java_home: &Path) -> Option<ReleaseInfo> {
460    let release_path = java_home.join("release");
461    debug!("read_release: checking {}", release_path.display());
462    let file = File::open(release_path).ok()?;
463    let reader = BufReader::new(file);
464    let mut properties = HashMap::new();
465
466    for line in reader.lines() {
467        let line = line.ok()?;
468        let line = line.trim();
469        if line.is_empty() || line.starts_with('#') {
470            continue;
471        }
472        let mut parts = line.splitn(2, '=');
473        if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
474            let key = key.trim().to_string();
475            let value = value.trim().trim_matches('"').to_string();
476            properties.insert(key, value);
477        }
478    }
479
480    Some(ReleaseInfo {
481        name: properties.get("IMPLEMENTOR").cloned(),
482        version: properties.get("JAVA_VERSION").cloned(),
483        vendor: properties.get("IMPLEMENTOR").cloned(),
484        arch: properties.get("OS_ARCH").cloned(),
485    })
486}
487
488// -----------------------------------------------------------------------------
489// Data extracted from `java -version` output
490// -----------------------------------------------------------------------------
491struct VersionInfo {
492    name: Option<String>,
493    version: Option<String>,
494    vendor: Option<String>,
495    arch: Option<String>,
496}
497
498fn read_version(java_exe: &Path) -> Result<VersionInfo, JavaError> {
499    debug!("read_version: running {}", java_exe.display());
500    let output = Command::new(java_exe)
501        .arg("-version")
502        .output()
503        .map_err(|e| JavaError::ExecuteError(format!("Failed to execute java -version: {}", e)))?;
504
505    if !output.status.success() {
506        return Err(JavaError::ExecuteError(format!(
507            "java -version command failed with status: {}",
508            output.status
509        )));
510    }
511
512    let stderr = str::from_utf8(&output.stderr).map_err(|e| {
513        JavaError::RuntimeError(format!("Failed to decode java -version output: {}", e))
514    })?;
515
516    let mut version = None;
517    let mut vendor = None;
518    let mut arch = None;
519
520    for line in stderr.lines() {
521        // Extract version from lines like `openjdk version "11.0.2" 2019-01-15`
522        if line.contains(" version ")
523            && let Some(start) = line.find('"')
524            && let Some(end) = line[start + 1..].find('"')
525        {
526            version = Some(line[start + 1..start + 1 + end].to_string());
527        }
528
529        // Extract vendor from "Runtime Environment" line
530        if line.contains("Runtime Environment")
531            && let Some(idx) = line.find("Runtime Environment")
532        {
533            let rest = &line[idx + "Runtime Environment".len()..];
534            let vendor_part = rest.split_whitespace().next().unwrap_or("");
535            let vendor_cleaned = vendor_part
536                .split(['-', '('])
537                .next()
538                .unwrap_or("")
539                .to_string();
540            if !vendor_cleaned.is_empty() {
541                vendor = Some(vendor_cleaned);
542            }
543        }
544
545        // Extract architecture (64‑Bit / 32‑Bit)
546        if line.contains("VM") && line.contains("Bit") {
547            if line.contains("64-Bit") {
548                arch = Some("64-Bit".to_string());
549            } else if line.contains("32-Bit") {
550                arch = Some("32-Bit".to_string());
551            }
552        }
553    }
554
555    // Fallback vendor from first line
556    if vendor.is_none()
557        && let Some(first_line) = stderr.lines().next()
558    {
559        if first_line.starts_with("openjdk") {
560            vendor = Some("OpenJDK".to_string());
561        } else if first_line.starts_with("java") {
562            vendor = Some("Oracle".to_string());
563        }
564    }
565
566    // Name is derived from vendor (keeps original behaviour)
567    let name = vendor.clone();
568
569    Ok(VersionInfo {
570        name,
571        version,
572        vendor,
573        arch,
574    })
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    #[test]
582    fn test_parse_java_8() {
583        let v = JavaVersion::parse("1.8.0_202-ea").unwrap();
584        assert_eq!(v.major, 8);
585        assert_eq!(v.minor, 0);
586        assert_eq!(v.patch, 202);
587        assert_eq!(v.raw, "1.8.0_202-ea");
588    }
589
590    #[test]
591    fn test_parse_java_11() {
592        let v = JavaVersion::parse("11.0.2").unwrap();
593        assert_eq!(v.major, 11);
594        assert_eq!(v.minor, 0);
595        assert_eq!(v.patch, 2);
596    }
597
598    #[test]
599    fn test_parse_java_17() {
600        let v = JavaVersion::parse("17.0.2+13-LTS").unwrap();
601        assert_eq!(v.major, 17);
602        assert_eq!(v.minor, 0);
603        assert_eq!(v.patch, 2);
604    }
605
606    #[test]
607    fn test_parse_java_21() {
608        let v = JavaVersion::parse("21.0.1+12").unwrap();
609        assert_eq!(v.major, 21);
610        assert_eq!(v.minor, 0);
611        assert_eq!(v.patch, 1);
612    }
613
614    #[test]
615    fn test_parse_ea_no_patch() {
616        let v = JavaVersion::parse("11-ea").unwrap();
617        assert_eq!(v.major, 11);
618        assert_eq!(v.minor, 0);
619        assert_eq!(v.patch, 0);
620    }
621
622    #[test]
623    fn test_parse_extra_segments() {
624        let v = JavaVersion::parse("17.0.8.1").unwrap();
625        assert_eq!(v.major, 17);
626        assert_eq!(v.minor, 0);
627        assert_eq!(v.patch, 8);
628    }
629
630    #[test]
631    fn test_parse_no_input() {
632        assert!(JavaVersion::parse("").is_none());
633    }
634
635    #[test]
636    fn test_parse_garbage() {
637        assert!(JavaVersion::parse("not.a.version").is_none());
638    }
639
640    #[test]
641    fn test_parse_letters_only() {
642        assert!(JavaVersion::parse("abc.def").is_none());
643    }
644
645    #[test]
646    fn test_display_normal() {
647        let v = JavaVersion::parse("11.0.2").unwrap();
648        assert_eq!(v.to_string(), "11.0.2");
649    }
650
651    #[test]
652    fn test_display_fallback_raw() {
653        let v = JavaVersion {
654            major: 0,
655            minor: 0,
656            patch: 0,
657            raw: "1.8.0_202".into(),
658        };
659        assert_eq!(v.to_string(), "1.8.0_202");
660    }
661
662    #[test]
663    fn test_matches_version_exact() {
664        let mut info = JavaInfo::default();
665        info.version = "11.0.2".into();
666        info.parsed_version = JavaVersion::parse("11.0.2");
667        assert!(info.matches_version("11.0.2"));
668    }
669
670    #[test]
671    fn test_matches_version_major_only() {
672        let mut info = JavaInfo::default();
673        info.version = "11.0.2".into();
674        info.parsed_version = JavaVersion::parse("11.0.2");
675        assert!(info.matches_version("11"));
676    }
677
678    #[test]
679    fn test_matches_version_major_minor() {
680        let mut info = JavaInfo::default();
681        info.version = "11.0.2".into();
682        info.parsed_version = JavaVersion::parse("11.0.2");
683        assert!(info.matches_version("11.0"));
684    }
685
686    #[test]
687    fn test_matches_version_no_match() {
688        let mut info = JavaInfo::default();
689        info.version = "11.0.2".into();
690        info.parsed_version = JavaVersion::parse("11.0.2");
691        assert!(!info.matches_version("17"));
692    }
693
694    #[test]
695    fn test_matches_version_unparseable() {
696        let info = JavaInfo::default();
697        assert!(!info.matches_version("11"));
698    }
699
700    #[test]
701    fn test_matches_version_invalid_req() {
702        let mut info = JavaInfo::default();
703        info.version = "11.0.2".into();
704        info.parsed_version = JavaVersion::parse("11.0.2");
705        assert!(!info.matches_version("abc"));
706    }
707
708    #[test]
709    fn test_matches_version_too_many_parts() {
710        let mut info = JavaInfo::default();
711        info.version = "11.0.2".into();
712        info.parsed_version = JavaVersion::parse("11.0.2");
713        assert!(!info.matches_version("11.0.2.1"));
714    }
715
716    #[test]
717    fn test_java_version_ordering() {
718        let v8 = JavaVersion::parse("1.8.0_202").unwrap();
719        let v11 = JavaVersion::parse("11.0.2").unwrap();
720        let v17 = JavaVersion::parse("17.0.1").unwrap();
721        assert!(v8 < v11);
722        assert!(v11 < v17);
723        assert!(v17 > v11);
724    }
725
726    #[test]
727    fn test_java_version_eq() {
728        let a = JavaVersion::parse("11.0.2").unwrap();
729        let b = JavaVersion::parse("11.0.2").unwrap();
730        assert_eq!(a, b);
731    }
732
733    #[test]
734    fn test_java_version_ordering_same_major() {
735        let a = JavaVersion::parse("11.0.2").unwrap();
736        let b = JavaVersion::parse("11.0.3").unwrap();
737        assert!(a < b);
738        assert!(b > a);
739    }
740
741    #[test]
742    fn test_java_info_default() {
743        let info = JavaInfo::default();
744        assert_eq!(info.name, "UNKNOWN");
745        assert_eq!(info.version, "UNKNOWN");
746        assert!(info.parsed_version.is_none());
747        assert!(info.path.to_string_lossy().is_empty());
748    }
749
750    #[test]
751    fn test_is_jdk_true() {
752        let dir = tempfile::tempdir().unwrap();
753        let bin = dir.path().join("bin");
754        std::fs::create_dir(&bin).unwrap();
755        let javac_path = bin.join(if cfg!(windows) { "javac.exe" } else { "javac" });
756        std::fs::write(&javac_path, "").unwrap();
757
758        let info = JavaInfo {
759            java_home: dir.path().to_path_buf(),
760            ..Default::default()
761        };
762        assert!(info.is_jdk());
763    }
764
765    #[test]
766    fn test_is_jdk_false() {
767        let dir = tempfile::tempdir().unwrap();
768        let info = JavaInfo {
769            java_home: dir.path().to_path_buf(),
770            ..Default::default()
771        };
772        assert!(!info.is_jdk());
773    }
774
775    #[test]
776    fn test_capabilities_returns_matching_tools() {
777        let dir = tempfile::tempdir().unwrap();
778        let bin = dir.path().join("bin");
779        std::fs::create_dir(&bin).unwrap();
780
781        let tool = if cfg!(windows) { "javac.exe" } else { "javac" };
782        std::fs::write(bin.join(tool), "").unwrap();
783
784        // Also create a tool that is NOT in the known list
785        let other = if cfg!(windows) { "other.exe" } else { "other" };
786        std::fs::write(bin.join(other), "").unwrap();
787
788        let info = JavaInfo {
789            java_home: dir.path().to_path_buf(),
790            ..Default::default()
791        };
792        let caps = info.capabilities();
793        assert!(caps.contains(&"javac".to_string()));
794        assert!(!caps.contains(&"other".to_string()));
795    }
796
797    #[test]
798    fn test_capabilities_empty_dir() {
799        let dir = tempfile::tempdir().unwrap();
800        let info = JavaInfo {
801            java_home: dir.path().to_path_buf(),
802            ..Default::default()
803        };
804        assert!(info.capabilities().is_empty());
805    }
806
807    #[test]
808    fn test_capabilities_nonexistent_dir() {
809        let info = JavaInfo {
810            java_home: PathBuf::from("/nonexistent/path"),
811            ..Default::default()
812        };
813        assert!(info.capabilities().is_empty());
814    }
815}