1use 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#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct JavaVersion {
27 pub major: u64,
32
33 pub minor: u64,
37
38 pub patch: u64,
42
43 pub raw: String,
47}
48
49impl JavaVersion {
50 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#[derive(Debug)]
110pub struct JavaInfo {
111 pub name: String,
113 pub version: String,
115 pub parsed_version: Option<JavaVersion>,
117 pub path: PathBuf,
119 pub vendor: String,
121 pub architecture: String,
123 pub java_home: PathBuf,
125}
126
127impl JavaInfo {
128 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 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 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 (canonical_path, None)
181 };
182
183 let java_exe = java_home
185 .join("bin")
186 .join(if cfg!(windows) { "java.exe" } else { "java" });
187 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 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 info.is_complete() {
222 return Ok(info);
223 }
224
225 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 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 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 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 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 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
458fn 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
474fn 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
497struct 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
536struct 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 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 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 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 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 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 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 #[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 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 #[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 #[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}