Skip to main content

java_manager/
search.rs

1//! System-level discovery of Java installations.
2//!
3//! Provides three tiers of search:
4//!
5//! | Function | Scope |
6//! |---|---|
7//! | [`quick_search`] | Walks every directory in `$PATH` looking for `java` (fastest) |
8//! | [`deep_search`] | Windows: Everything SDK (falls back to [`full_search`]).<br>Linux/macOS: delegates to [`full_search`] |
9//! | [`full_search`] | Registry, keyword BFS, Microsoft Store, `where` command,<br>package managers (Chocolatey, Scoop, SDKMAN, Homebrew,<br>JBang, asdf-vm), JetBrains IDE-bundled JDKs,<br>JVM directories, Minecraft runtime |
10//!
11//! Search results are automatically deduplicated: when both a JDK and its
12//! bundled JRE are found, only the JDK-level entry is returned.
13
14use crate::{JavaError, JavaInfo};
15use log::debug;
16use std::collections::VecDeque;
17use std::env;
18use std::fs;
19use std::path::{Path, PathBuf};
20
21const MAX_SEARCH_DEPTH: usize = 6;
22
23const JAVA_KEYWORDS: &[&str] = &["java", "jdk", "jre", "jvm", "openjdk", "graalvm"];
24
25const EXCLUDE_FOLDERS: &[&str] = &[
26    "node_modules",
27    ".git",
28    "__pycache__",
29    "vendor",
30    "cache",
31    "temp",
32    "tmp",
33    "logs",
34    "log",
35];
36
37/// Searches for Java installations by scanning every directory in `$PATH`.
38///
39/// This is the fastest search method — it only checks what is immediately
40/// available on the system `PATH`. On Windows it looks for `java.exe`;
41/// on Linux and macOS it looks for `java`.
42///
43/// # Returns
44///
45/// A `Vec<JavaInfo>` for every valid `java` executable found in `PATH`.
46///
47/// # Errors
48///
49/// Returns [`JavaError::IoError`] if a file-system operation fails when
50/// inspecting a candidate path. Individual invalid or unreadable paths are
51/// skipped silently.
52///
53/// # Example
54///
55/// ```no_run
56/// use java_manager::quick_search;
57///
58/// let javas = quick_search()?;
59/// for java in javas {
60///     println!("{} at {}", java.version, java.path.display());
61/// }
62/// # Ok::<_, java_manager::JavaError>(())
63/// ```
64pub fn quick_search() -> Result<Vec<JavaInfo>, JavaError> {
65    let mut results: Vec<JavaInfo> = Vec::new();
66
67    if let Ok(paths) = env::var("PATH") {
68        for path in env::split_paths(&paths) {
69            let java_exe = if cfg!(windows) { "java.exe" } else { "java" };
70            let java_path = path.join(java_exe);
71
72            debug!("quick_search: checking PATH entry {:?}", java_path);
73            if java_path.is_file()
74                && let Ok(info) = JavaInfo::new(java_path.to_string_lossy().to_string())
75            {
76                debug!(
77                    "quick_search: found {} at {}",
78                    info.version,
79                    java_path.display()
80                );
81                results.push(info);
82            }
83        }
84    }
85
86    debug!("quick_search: found {} Java installation(s)", results.len());
87    Ok(results)
88}
89
90/// Remove nested JRE entries that are bundled under a parent JDK.
91///
92/// When both `jdk-xxx/bin/java.exe` and `jdk-xxx/jre/bin/java.exe` are
93/// discovered, only the JDK-level installation is kept. Standalone JREs
94/// (whose `java_home` is not a subdirectory of another entry) are unaffected.
95fn dedup_nested(javas: Vec<JavaInfo>) -> Vec<JavaInfo> {
96    let mut keep = vec![true; javas.len()];
97    for i in 0..javas.len() {
98        for j in 0..javas.len() {
99            if i != j && javas[i].java_home.starts_with(&javas[j].java_home) {
100                keep[i] = false;
101            }
102        }
103    }
104    javas
105        .into_iter()
106        .enumerate()
107        .filter(|(i, _)| keep[*i])
108        .map(|(_, j)| j)
109        .collect()
110}
111
112/// Performs a deep, platform-aware search for Java installations.
113///
114/// **Windows**: Uses the Everything SDK for near-instant results. If the
115/// Everything service is not running or the SDK is unavailable, automatically
116/// falls back to [`full_search`].
117///
118/// **Linux / macOS**: Delegates directly to [`full_search`], which walks
119/// standard JVM directories and checks SDKMAN, JBang, asdf-vm, Homebrew,
120/// JetBrains IDE-bundled JDKs, and Minecraft runtimes.
121///
122/// # Returns
123///
124/// A `Vec<JavaInfo>` for every valid Java installation found.
125///
126/// # Errors
127///
128/// Returns [`JavaError::IoError`] if file-system operations fail.
129/// On Windows, an Everything IPC error is silently caught and falls back
130/// to [`full_search`] — this never propagates to the caller.
131///
132/// # Example
133///
134/// ```no_run
135/// use java_manager::deep_search;
136///
137/// let javas = deep_search()?;
138/// println!("Found {} Java installation(s)", javas.len());
139/// # Ok::<_, java_manager::JavaError>(())
140/// ```
141pub fn deep_search() -> Result<Vec<JavaInfo>, JavaError> {
142    #[cfg(target_os = "windows")]
143    {
144        match deep_search_everything() {
145            Ok(results) => return Ok(results),
146            Err(e) => {
147                debug!(
148                    "deep_search: Everything SDK unavailable ({}), falling back to full_search",
149                    e
150                );
151            }
152        }
153        full_search()
154    }
155
156    #[cfg(target_os = "linux")]
157    {
158        full_search()
159    }
160
161    #[cfg(target_os = "macos")]
162    {
163        full_search()
164    }
165
166    #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
167    {
168        Ok(Vec::new())
169    }
170}
171
172/// Performs a comprehensive, multi-strategy scan for Java installations.
173///
174/// This is the most thorough search method. It checks `$JAVA_HOME` first,
175/// then applies every available discovery strategy for the current platform:
176///
177/// **Windows**: Registry (HKLM + HKCU for JavaSoft, Azul, BellSoft, Temurin,
178/// Corretto, GraalVM) → Keyword BFS on all drives → Microsoft Store →
179/// `where java` → Chocolatey → Scoop → JetBrains IDE-bundled JDK.
180///
181/// **Linux**: `/usr/lib/jvm` → `/usr/java` → `/opt` → `/usr/local` →
182/// SDKMAN → JBang → asdf-vm → JetBrains IDE-bundled JDK → Minecraft runtime.
183///
184/// **macOS**: `/Library/Java/JavaVirtualMachines` →
185/// `/usr/libexec/java_home` → SDKMAN → JBang → asdf-vm → Homebrew →
186/// JetBrains IDE-bundled JDK → Minecraft runtime.
187///
188/// Results are automatically deduplicated — when both a JDK and its bundled
189/// JRE are discovered, only the JDK-level entry is kept.
190///
191/// # Returns
192///
193/// A `Vec<JavaInfo>` for every valid Java installation found.
194///
195/// # Errors
196///
197/// Returns [`JavaError::IoError`] if file-system operations fail.
198/// Individual unreadable paths are skipped silently.
199///
200/// # Example
201///
202/// ```no_run
203/// use java_manager::full_search;
204///
205/// let javas = full_search()?;
206/// for java in &javas {
207///     println!("{:25} {}", java.name, java.version);
208/// }
209/// # Ok::<_, java_manager::JavaError>(())
210/// ```
211pub fn full_search() -> Result<Vec<JavaInfo>, JavaError> {
212    let mut paths = Vec::new();
213
214    if let Some(jh) = env::var_os("JAVA_HOME") {
215        let java_exe =
216            Path::new(&jh)
217                .join("bin")
218                .join(if cfg!(windows) { "java.exe" } else { "java" });
219        let jh_str = java_exe.to_string_lossy();
220        debug!("full_search: checking JAVA_HOME at {jh_str}");
221        if java_exe.exists() {
222            debug!("full_search: JAVA_HOME -> {jh_str}");
223            paths.push(java_exe);
224        }
225    }
226
227    #[cfg(target_os = "windows")]
228    {
229        paths.extend(scan_registry());
230        paths.extend(scan_default_paths());
231        paths.extend(scan_chocolatey());
232        paths.extend(scan_scoop());
233        paths.extend(scan_jetbrains_windows());
234        paths.extend(scan_microsoft_store());
235        paths.extend(scan_where_command());
236    }
237
238    #[cfg(target_os = "linux")]
239    {
240        paths.extend(scan_linux());
241        paths.extend(scan_sdkman_linux());
242        paths.extend(scan_jbang_linux());
243        paths.extend(scan_asdf_linux());
244        paths.extend(scan_jetbrains_linux());
245    }
246
247    #[cfg(target_os = "macos")]
248    {
249        paths.extend(scan_macos());
250        paths.extend(scan_sdkman_macos());
251        paths.extend(scan_jbang_macos());
252        paths.extend(scan_asdf_macos());
253        paths.extend(scan_homebrew_macos());
254        paths.extend(scan_jetbrains_macos());
255    }
256
257    paths.sort();
258    paths.dedup();
259
260    debug!(
261        "full_search: {} candidate path(s) before JavaInfo resolution",
262        paths.len()
263    );
264
265    let javas: Vec<JavaInfo> = paths
266        .into_iter()
267        .filter_map(|p| {
268            let result = JavaInfo::new(p.to_string_lossy().to_string());
269            if result.is_err() {
270                debug!("full_search: skipping invalid path {}", p.display());
271            }
272            result.ok()
273        })
274        .collect();
275
276    let count_before = javas.len();
277    let javas = dedup_nested(javas);
278    let removed = count_before - javas.len();
279    if removed > 0 {
280        debug!("full_search: removed {removed} nested JRE entr(ies)");
281    }
282    Ok(javas)
283}
284
285/// Same as [`full_search`] but parallelises `JavaInfo` resolution with rayon.
286///
287/// The search strategy is identical to [`full_search`]. The difference is that
288/// `JavaInfo::new()` calls (which spawn a `java -version` process for each
289/// candidate) run concurrently via rayon, significantly speeding up scans
290/// when many candidates are discovered.
291///
292/// Only available with the `parallel` feature enabled.
293///
294/// # Returns
295///
296/// A `Vec<JavaInfo>` for every valid Java installation found.
297///
298/// # Errors
299///
300/// Returns [`JavaError::IoError`] if file-system operations fail.
301///
302/// # Example
303///
304/// ```no_run
305/// # #[cfg(feature = "parallel")]
306/// # {
307/// use java_manager::parallel_full_search;
308///
309/// let javas = parallel_full_search()?;
310/// println!("Found {} Java installation(s)", javas.len());
311/// # }
312/// # Ok::<_, java_manager::JavaError>(())
313/// ```
314#[cfg(feature = "parallel")]
315pub fn parallel_full_search() -> Result<Vec<JavaInfo>, JavaError> {
316    use rayon::prelude::*;
317
318    let mut paths = Vec::new();
319
320    if let Some(jh) = env::var_os("JAVA_HOME") {
321        let java_exe =
322            Path::new(&jh)
323                .join("bin")
324                .join(if cfg!(windows) { "java.exe" } else { "java" });
325        let jh_str = java_exe.to_string_lossy();
326        debug!("full_search: checking JAVA_HOME at {jh_str}");
327        if java_exe.exists() {
328            debug!("full_search: JAVA_HOME -> {jh_str}");
329            paths.push(java_exe);
330        }
331    }
332
333    #[cfg(target_os = "windows")]
334    {
335        paths.par_extend(scan_registry());
336        paths.par_extend(scan_default_paths());
337        paths.par_extend(scan_chocolatey());
338        paths.par_extend(scan_scoop());
339        paths.par_extend(scan_jetbrains_windows());
340        paths.par_extend(scan_microsoft_store());
341        paths.par_extend(scan_where_command());
342    }
343
344    #[cfg(target_os = "linux")]
345    {
346        paths.par_extend(scan_linux());
347        paths.par_extend(scan_sdkman_linux());
348        paths.par_extend(scan_jbang_linux());
349        paths.par_extend(scan_asdf_linux());
350        paths.par_extend(scan_jetbrains_linux());
351    }
352
353    #[cfg(target_os = "macos")]
354    {
355        paths.par_extend(scan_macos());
356        paths.par_extend(scan_sdkman_macos());
357        paths.par_extend(scan_jbang_macos());
358        paths.par_extend(scan_asdf_macos());
359        paths.par_extend(scan_homebrew_macos());
360        paths.par_extend(scan_jetbrains_macos());
361    }
362
363    paths.par_sort_unstable();
364    paths.dedup();
365
366    debug!(
367        "parallel_full_search: {} candidate path(s) before JavaInfo resolution",
368        paths.len()
369    );
370
371    let javas: Vec<JavaInfo> = paths
372        .into_par_iter()
373        .filter_map(|p| {
374            let result = JavaInfo::new(p.to_string_lossy().to_string());
375            if result.is_err() {
376                debug!(
377                    "parallel_full_search: skipping invalid path {}",
378                    p.display()
379                );
380            }
381            result.ok()
382        })
383        .collect();
384
385    let count_before = javas.len();
386    let javas = dedup_nested(javas);
387    let removed = count_before - javas.len();
388    if removed > 0 {
389        debug!("parallel_full_search: removed {removed} nested JRE entr(ies)");
390    }
391    Ok(javas)
392}
393
394#[cfg(target_os = "windows")]
395fn deep_search_everything() -> Result<Vec<JavaInfo>, JavaError> {
396    use everything_sdk::*;
397
398    let mut results: Vec<JavaInfo> = Vec::new();
399    let mut everything = global().try_lock().map_err(|_| {
400        JavaError::RuntimeError("Failed to lock Everything global state".to_string())
401    })?;
402
403    match everything.is_db_loaded() {
404        Ok(false) => {
405            debug!("deep_search_everything: database not fully loaded");
406            return Err(JavaError::ExecuteError(
407                "Everything database is not fully loaded".to_string(),
408            ));
409        }
410        Err(EverythingError::Ipc) => {
411            debug!("deep_search_everything: Everything IPC unavailable (not running)");
412            return Err(JavaError::ExecuteError(
413                "Everything is not running in the background. Please start Everything.exe"
414                    .to_string(),
415            ));
416        }
417        _ => {}
418    }
419
420    debug!("deep_search_everything: querying Everything SDK");
421    let mut searcher = everything.searcher();
422    searcher.set_search("\"java.exe\" !C:\\Windows\\");
423    searcher.set_request_flags(
424        RequestFlags::EVERYTHING_REQUEST_FILE_NAME
425            | RequestFlags::EVERYTHING_REQUEST_PATH
426            | RequestFlags::EVERYTHING_REQUEST_SIZE,
427    );
428    searcher.set_sort(SortType::EVERYTHING_SORT_NAME_ASCENDING);
429
430    assert!(!searcher.get_match_case());
431
432    let query_results = searcher.query();
433
434    for item in query_results.iter() {
435        if let Ok(path) = item.filepath()
436            && let Ok(info) = JavaInfo::new(path.to_string_lossy().to_string())
437        {
438            debug!(
439                "deep_search_everything: found {} at {}",
440                info.version,
441                path.display()
442            );
443            results.push(info);
444        }
445    }
446
447    debug!(
448        "deep_search_everything: found {} Java installation(s)",
449        results.len()
450    );
451    Ok(results)
452}
453
454#[cfg(target_os = "windows")]
455fn scan_registry() -> Vec<PathBuf> {
456    use winreg::RegKey;
457    use winreg::enums::*;
458
459    let mut results = Vec::new();
460
461    let entries: &[(*mut std::ffi::c_void, &str, &str)] = &[
462        // JavaSoft — HKLM
463        (
464            HKEY_LOCAL_MACHINE,
465            r"SOFTWARE\JavaSoft\Java Development Kit",
466            "JavaHome",
467        ),
468        (
469            HKEY_LOCAL_MACHINE,
470            r"SOFTWARE\JavaSoft\Java Runtime Environment",
471            "JavaHome",
472        ),
473        (
474            HKEY_LOCAL_MACHINE,
475            r"SOFTWARE\WOW6432Node\JavaSoft\Java Development Kit",
476            "JavaHome",
477        ),
478        (
479            HKEY_LOCAL_MACHINE,
480            r"SOFTWARE\WOW6432Node\JavaSoft\Java Runtime Environment",
481            "JavaHome",
482        ),
483        // JavaSoft — HKCU
484        (
485            HKEY_CURRENT_USER,
486            r"SOFTWARE\JavaSoft\Java Development Kit",
487            "JavaHome",
488        ),
489        (
490            HKEY_CURRENT_USER,
491            r"SOFTWARE\JavaSoft\Java Runtime Environment",
492            "JavaHome",
493        ),
494        (
495            HKEY_CURRENT_USER,
496            r"SOFTWARE\WOW6432Node\JavaSoft\Java Development Kit",
497            "JavaHome",
498        ),
499        (
500            HKEY_CURRENT_USER,
501            r"SOFTWARE\WOW6432Node\JavaSoft\Java Runtime Environment",
502            "JavaHome",
503        ),
504        // Brand-specific
505        (
506            HKEY_LOCAL_MACHINE,
507            r"SOFTWARE\Azul Systems\Zulu",
508            "InstallationPath",
509        ),
510        (
511            HKEY_LOCAL_MACHINE,
512            r"SOFTWARE\BellSoft\Liberica",
513            "InstallationPath",
514        ),
515        (
516            HKEY_LOCAL_MACHINE,
517            r"SOFTWARE\Eclipse Foundation\Temurin",
518            "InstallationPath",
519        ),
520        (
521            HKEY_CURRENT_USER,
522            r"SOFTWARE\Eclipse Foundation\Temurin",
523            "InstallationPath",
524        ),
525        (
526            HKEY_LOCAL_MACHINE,
527            r"SOFTWARE\Amazon Corretto",
528            "InstallationPath",
529        ),
530        (
531            HKEY_CURRENT_USER,
532            r"SOFTWARE\Amazon Corretto",
533            "InstallationPath",
534        ),
535        (HKEY_LOCAL_MACHINE, r"SOFTWARE\GraalVM", "InstallationPath"),
536        (HKEY_CURRENT_USER, r"SOFTWARE\GraalVM", "InstallationPath"),
537    ];
538
539    for &(root, subpath, value_name) in entries {
540        debug!("scan_registry: checking {subpath}");
541        let key = match RegKey::predef(root).open_subkey(subpath) {
542            Ok(k) => k,
543            Err(_) => continue,
544        };
545        for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
546            let subkey = match key.open_subkey(subkey_name) {
547                Ok(sk) => sk,
548                Err(_) => continue,
549            };
550            let mut install_path: String = match subkey.get_value(value_name) {
551                Ok(v) => v,
552                Err(_) => continue,
553            };
554            install_path = install_path.trim_end_matches('\\').to_string();
555            let java_exe = Path::new(&install_path).join("bin").join("java.exe");
556            if java_exe.exists() {
557                debug!("scan_registry: found valid Java at {install_path}");
558                results.push(java_exe);
559            } else {
560                debug!("scan_registry: stale registry entry at {install_path}");
561            }
562        }
563    }
564
565    results
566}
567
568// -----------------------------------------------------------------------------
569// Chocolatey (Windows)
570// -----------------------------------------------------------------------------
571#[cfg(target_os = "windows")]
572fn scan_chocolatey() -> Vec<PathBuf> {
573    let prog_data = match env::var_os("ProgramData") {
574        Some(v) => Path::new(&v).join("chocolatey").join("lib"),
575        None => return Vec::new(),
576    };
577
578    debug!("scan_chocolatey: checking {}", prog_data.display());
579    if !prog_data.exists() {
580        return Vec::new();
581    }
582
583    let mut results = Vec::new();
584    if let Ok(entries) = fs::read_dir(&prog_data) {
585        for entry in entries.flatten() {
586            let dir = entry.path();
587            if !dir.is_dir() {
588                continue;
589            }
590            let java_exe = dir.join("bin").join("java.exe");
591            if java_exe.exists() {
592                debug!("scan_chocolatey: found Java at {}", java_exe.display());
593                results.push(java_exe);
594            }
595        }
596    }
597    results
598}
599
600// -----------------------------------------------------------------------------
601// Scoop (Windows)
602// -----------------------------------------------------------------------------
603#[cfg(target_os = "windows")]
604fn scan_scoop() -> Vec<PathBuf> {
605    let scoop_home = if let Some(v) = env::var_os("SCOOP") {
606        Path::new(&v).to_path_buf()
607    } else if let Some(v) = env::var_os("USERPROFILE") {
608        Path::new(&v).join("scoop").join("apps")
609    } else {
610        return Vec::new();
611    };
612
613    debug!("scan_scoop: checking {}", scoop_home.display());
614    if !scoop_home.exists() {
615        return Vec::new();
616    }
617
618    let mut results = Vec::new();
619    if let Ok(entries) = fs::read_dir(&scoop_home) {
620        for entry in entries.flatten() {
621            let dir_name = entry.file_name().to_string_lossy().to_lowercase();
622            if !JAVA_KEYWORDS.iter().any(|kw| dir_name.contains(kw)) {
623                continue;
624            }
625            let current_java = entry.path().join("current").join("bin").join("java.exe");
626            if current_java.exists() {
627                debug!("scan_scoop: found Java at {}", current_java.display());
628                results.push(current_java);
629            }
630        }
631    }
632    results
633}
634
635// -----------------------------------------------------------------------------
636// JetBrains IDE-bundled JDK (Windows)
637// -----------------------------------------------------------------------------
638#[cfg(target_os = "windows")]
639fn scan_jetbrains_windows() -> Vec<PathBuf> {
640    let mut results = Vec::new();
641
642    let search_roots: Vec<PathBuf> = {
643        let mut roots = Vec::new();
644        if let Some(localappdata) = env::var_os("LOCALAPPDATA") {
645            roots.push(Path::new(&localappdata).join("JetBrains"));
646        }
647        if let Some(prog_files) = env::var_os("ProgramFiles") {
648            roots.push(Path::new(&prog_files).join("JetBrains"));
649        }
650        if let Some(prog_files_x86) = env::var_os("ProgramFiles(x86)") {
651            roots.push(Path::new(&prog_files_x86).join("JetBrains"));
652        }
653        roots
654    };
655
656    for root in search_roots {
657        if !root.exists() {
658            continue;
659        }
660        debug!("scan_jetbrains_windows: checking {}", root.display());
661        if let Ok(entries) = fs::read_dir(&root) {
662            for entry in entries.flatten() {
663                let jbr = entry.path().join("jbr").join("bin").join("java.exe");
664                if jbr.exists() {
665                    debug!("scan_jetbrains_windows: found Java at {}", jbr.display());
666                    results.push(jbr);
667                }
668            }
669        }
670    }
671    results
672}
673
674// -----------------------------------------------------------------------------
675// SDKMAN (Linux)
676// -----------------------------------------------------------------------------
677#[cfg(target_os = "linux")]
678fn scan_sdkman_linux() -> Vec<PathBuf> {
679    let home = match env::var("HOME") {
680        Ok(h) => h,
681        Err(_) => return Vec::new(),
682    };
683    let sdkman_candidates = Path::new(&home)
684        .join(".sdkman")
685        .join("candidates")
686        .join("java");
687    if !sdkman_candidates.exists() {
688        return Vec::new();
689    }
690
691    debug!(
692        "scan_sdkman_linux: checking {}",
693        sdkman_candidates.display()
694    );
695    let mut results = Vec::new();
696
697    if let Ok(entries) = fs::read_dir(&sdkman_candidates) {
698        for entry in entries.flatten() {
699            let java_exe = entry.path().join("bin").join("java");
700            if java_exe.exists() {
701                #[cfg(unix)]
702                {
703                    use std::os::unix::fs::PermissionsExt;
704                    if let Ok(metadata) = java_exe.metadata() {
705                        if metadata.permissions().mode() & 0o111 != 0 {
706                            debug!("scan_sdkman_linux: found Java at {}", java_exe.display());
707                            results.push(java_exe);
708                        }
709                    }
710                }
711                #[cfg(not(unix))]
712                {
713                    results.push(java_exe);
714                }
715            }
716        }
717    }
718    results
719}
720
721// -----------------------------------------------------------------------------
722// JetBrains IDE-bundled JDK (Linux)
723// -----------------------------------------------------------------------------
724#[cfg(target_os = "linux")]
725fn scan_jetbrains_linux() -> Vec<PathBuf> {
726    let mut results = Vec::new();
727
728    let search_roots: Vec<PathBuf> = {
729        let mut roots: Vec<PathBuf> = vec![Path::new("/opt").join("JetBrains")];
730        if let Ok(home) = env::var("HOME") {
731            roots.push(
732                Path::new(&home)
733                    .join(".local")
734                    .join("share")
735                    .join("JetBrains"),
736            );
737        }
738        roots
739    };
740
741    for root in &search_roots {
742        if !root.exists() {
743            continue;
744        }
745        debug!("scan_jetbrains_linux: checking {}", root.display());
746        if let Ok(entries) = fs::read_dir(root) {
747            for entry in entries.flatten() {
748                let jbr = entry.path().join("jbr").join("bin").join("java");
749                if jbr.exists() {
750                    #[cfg(unix)]
751                    {
752                        use std::os::unix::fs::PermissionsExt;
753                        if let Ok(metadata) = jbr.metadata() {
754                            if metadata.permissions().mode() & 0o111 != 0 {
755                                debug!("scan_jetbrains_linux: found Java at {}", jbr.display());
756                                results.push(jbr);
757                            }
758                        }
759                    }
760                    #[cfg(not(unix))]
761                    {
762                        results.push(jbr);
763                    }
764                }
765            }
766        }
767    }
768
769    results
770}
771
772// -----------------------------------------------------------------------------
773// SDKMAN (macOS)
774// -----------------------------------------------------------------------------
775#[cfg(target_os = "macos")]
776fn scan_sdkman_macos() -> Vec<PathBuf> {
777    let home = match env::var("HOME") {
778        Ok(h) => h,
779        Err(_) => return Vec::new(),
780    };
781    let sdkman_candidates = Path::new(&home)
782        .join(".sdkman")
783        .join("candidates")
784        .join("java");
785    if !sdkman_candidates.exists() {
786        return Vec::new();
787    }
788
789    debug!(
790        "scan_sdkman_macos: checking {}",
791        sdkman_candidates.display()
792    );
793    let mut results = Vec::new();
794
795    if let Ok(entries) = fs::read_dir(&sdkman_candidates) {
796        for entry in entries.flatten() {
797            let java_exe = entry.path().join("bin").join("java");
798            if java_exe.exists() {
799                #[cfg(unix)]
800                {
801                    use std::os::unix::fs::PermissionsExt;
802                    if let Ok(metadata) = java_exe.metadata() {
803                        if metadata.permissions().mode() & 0o111 != 0 {
804                            debug!("scan_sdkman_macos: found Java at {}", java_exe.display());
805                            results.push(java_exe);
806                        }
807                    }
808                }
809                #[cfg(not(unix))]
810                {
811                    results.push(java_exe);
812                }
813            }
814        }
815    }
816    results
817}
818
819// -----------------------------------------------------------------------------
820// Homebrew (macOS)
821// -----------------------------------------------------------------------------
822#[cfg(target_os = "macos")]
823fn scan_homebrew_macos() -> Vec<PathBuf> {
824    let mut results = Vec::new();
825
826    let brew_roots: &[&str] = &["/opt/homebrew/opt", "/usr/local/opt"];
827
828    for root in brew_roots {
829        let root_path = Path::new(root);
830        if !root_path.exists() {
831            continue;
832        }
833        debug!("scan_homebrew_macos: checking {}", root);
834        if let Ok(entries) = fs::read_dir(root_path) {
835            for entry in entries.flatten() {
836                let dir_name = entry.file_name().to_string_lossy().to_lowercase();
837                if dir_name.starts_with("openjdk") || dir_name.starts_with("java") {
838                    let java_exe = entry.path().join("bin").join("java");
839                    if java_exe.exists() {
840                        #[cfg(unix)]
841                        {
842                            use std::os::unix::fs::PermissionsExt;
843                            if let Ok(metadata) = java_exe.metadata() {
844                                if metadata.permissions().mode() & 0o111 != 0 {
845                                    debug!(
846                                        "scan_homebrew_macos: found Java at {}",
847                                        java_exe.display()
848                                    );
849                                    results.push(java_exe);
850                                }
851                            }
852                        }
853                        #[cfg(not(unix))]
854                        {
855                            results.push(java_exe);
856                        }
857                    }
858                }
859            }
860        }
861    }
862    results
863}
864
865// -----------------------------------------------------------------------------
866// JetBrains IDE-bundled JDK (macOS)
867// -----------------------------------------------------------------------------
868#[cfg(target_os = "macos")]
869fn scan_jetbrains_macos() -> Vec<PathBuf> {
870    let mut results = Vec::new();
871
872    // /Applications/*.app/Contents/jbr/Contents/Home/bin/java
873    debug!("scan_jetbrains_macos: checking /Applications");
874    if let Ok(entries) = fs::read_dir("/Applications") {
875        for entry in entries.flatten() {
876            let app_name = entry.file_name().to_string_lossy().to_lowercase();
877            if !app_name.contains("jetbrains")
878                && !app_name.contains("intellij")
879                && !app_name.contains("android studio")
880            {
881                continue;
882            }
883            let jbr_home = entry
884                .path()
885                .join("Contents")
886                .join("jbr")
887                .join("Contents")
888                .join("Home");
889            let java_exe = jbr_home.join("bin").join("java");
890            if java_exe.exists() {
891                #[cfg(unix)]
892                {
893                    use std::os::unix::fs::PermissionsExt;
894                    if let Ok(metadata) = java_exe.metadata() {
895                        if metadata.permissions().mode() & 0o111 != 0 {
896                            debug!("scan_jetbrains_macos: found Java at {}", java_exe.display());
897                            results.push(java_exe);
898                        }
899                    }
900                }
901                #[cfg(not(unix))]
902                {
903                    results.push(java_exe);
904                }
905            }
906        }
907    }
908
909    // ~/Library/Application Support/JetBrains/*/jbr/Contents/Home/bin/java
910    if let Ok(home) = env::var("HOME") {
911        let jetbrains_support = Path::new(&home)
912            .join("Library")
913            .join("Application Support")
914            .join("JetBrains");
915        if jetbrains_support.exists() {
916            debug!(
917                "scan_jetbrains_macos: checking {}",
918                jetbrains_support.display()
919            );
920            if let Ok(entries) = fs::read_dir(&jetbrains_support) {
921                for entry in entries.flatten() {
922                    let jbr_home = entry.path().join("jbr").join("Contents").join("Home");
923                    let java_exe = jbr_home.join("bin").join("java");
924                    if java_exe.exists() {
925                        #[cfg(unix)]
926                        {
927                            use std::os::unix::fs::PermissionsExt;
928                            if let Ok(metadata) = java_exe.metadata() {
929                                if metadata.permissions().mode() & 0o111 != 0 {
930                                    debug!(
931                                        "scan_jetbrains_macos: found Java at {}",
932                                        java_exe.display()
933                                    );
934                                    results.push(java_exe);
935                                }
936                            }
937                        }
938                        #[cfg(not(unix))]
939                        {
940                            results.push(java_exe);
941                        }
942                    }
943                }
944            }
945        }
946    }
947
948    results
949}
950
951// -----------------------------------------------------------------------------
952// JBang (Linux)
953// -----------------------------------------------------------------------------
954#[cfg(target_os = "linux")]
955fn scan_jbang_linux() -> Vec<PathBuf> {
956    let home = match env::var("HOME") {
957        Ok(h) => h,
958        Err(_) => return Vec::new(),
959    };
960    let jbang_jdks = Path::new(&home).join(".jbang").join("cache").join("jdks");
961    if !jbang_jdks.exists() {
962        return Vec::new();
963    }
964
965    debug!("scan_jbang_linux: checking {}", jbang_jdks.display());
966    let mut results = Vec::new();
967
968    if let Ok(entries) = fs::read_dir(&jbang_jdks) {
969        for entry in entries.flatten() {
970            let java_exe = entry.path().join("bin").join("java");
971            if java_exe.exists() {
972                #[cfg(unix)]
973                {
974                    use std::os::unix::fs::PermissionsExt;
975                    if let Ok(metadata) = java_exe.metadata() {
976                        if metadata.permissions().mode() & 0o111 != 0 {
977                            debug!("scan_jbang_linux: found Java at {}", java_exe.display());
978                            results.push(java_exe);
979                        }
980                    }
981                }
982                #[cfg(not(unix))]
983                {
984                    results.push(java_exe);
985                }
986            }
987        }
988    }
989    results
990}
991
992// -----------------------------------------------------------------------------
993// asdf-vm (Linux)
994// -----------------------------------------------------------------------------
995#[cfg(target_os = "linux")]
996fn scan_asdf_linux() -> Vec<PathBuf> {
997    let home = match env::var("HOME") {
998        Ok(h) => h,
999        Err(_) => return Vec::new(),
1000    };
1001    let asdf_java = Path::new(&home).join(".asdf").join("installs").join("java");
1002    if !asdf_java.exists() {
1003        return Vec::new();
1004    }
1005
1006    debug!("scan_asdf_linux: checking {}", asdf_java.display());
1007    let mut results = Vec::new();
1008
1009    if let Ok(entries) = fs::read_dir(&asdf_java) {
1010        for entry in entries.flatten() {
1011            let java_exe = entry.path().join("bin").join("java");
1012            if java_exe.exists() {
1013                #[cfg(unix)]
1014                {
1015                    use std::os::unix::fs::PermissionsExt;
1016                    if let Ok(metadata) = java_exe.metadata() {
1017                        if metadata.permissions().mode() & 0o111 != 0 {
1018                            debug!("scan_asdf_linux: found Java at {}", java_exe.display());
1019                            results.push(java_exe);
1020                        }
1021                    }
1022                }
1023                #[cfg(not(unix))]
1024                {
1025                    results.push(java_exe);
1026                }
1027            }
1028        }
1029    }
1030    results
1031}
1032
1033// -----------------------------------------------------------------------------
1034// JBang (macOS)
1035// -----------------------------------------------------------------------------
1036#[cfg(target_os = "macos")]
1037fn scan_jbang_macos() -> Vec<PathBuf> {
1038    let home = match env::var("HOME") {
1039        Ok(h) => h,
1040        Err(_) => return Vec::new(),
1041    };
1042    let jbang_jdks = Path::new(&home).join(".jbang").join("cache").join("jdks");
1043    if !jbang_jdks.exists() {
1044        return Vec::new();
1045    }
1046
1047    debug!("scan_jbang_macos: checking {}", jbang_jdks.display());
1048    let mut results = Vec::new();
1049
1050    if let Ok(entries) = fs::read_dir(&jbang_jdks) {
1051        for entry in entries.flatten() {
1052            let java_exe = entry.path().join("bin").join("java");
1053            if java_exe.exists() {
1054                #[cfg(unix)]
1055                {
1056                    use std::os::unix::fs::PermissionsExt;
1057                    if let Ok(metadata) = java_exe.metadata() {
1058                        if metadata.permissions().mode() & 0o111 != 0 {
1059                            debug!("scan_jbang_macos: found Java at {}", java_exe.display());
1060                            results.push(java_exe);
1061                        }
1062                    }
1063                }
1064                #[cfg(not(unix))]
1065                {
1066                    results.push(java_exe);
1067                }
1068            }
1069        }
1070    }
1071    results
1072}
1073
1074// -----------------------------------------------------------------------------
1075// asdf-vm (macOS)
1076// -----------------------------------------------------------------------------
1077#[cfg(target_os = "macos")]
1078fn scan_asdf_macos() -> Vec<PathBuf> {
1079    let home = match env::var("HOME") {
1080        Ok(h) => h,
1081        Err(_) => return Vec::new(),
1082    };
1083    let asdf_java = Path::new(&home).join(".asdf").join("installs").join("java");
1084    if !asdf_java.exists() {
1085        return Vec::new();
1086    }
1087
1088    debug!("scan_asdf_macos: checking {}", asdf_java.display());
1089    let mut results = Vec::new();
1090
1091    if let Ok(entries) = fs::read_dir(&asdf_java) {
1092        for entry in entries.flatten() {
1093            let java_exe = entry.path().join("bin").join("java");
1094            if java_exe.exists() {
1095                #[cfg(unix)]
1096                {
1097                    use std::os::unix::fs::PermissionsExt;
1098                    if let Ok(metadata) = java_exe.metadata() {
1099                        if metadata.permissions().mode() & 0o111 != 0 {
1100                            debug!("scan_asdf_macos: found Java at {}", java_exe.display());
1101                            results.push(java_exe);
1102                        }
1103                    }
1104                }
1105                #[cfg(not(unix))]
1106                {
1107                    results.push(java_exe);
1108                }
1109            }
1110        }
1111    }
1112    results
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118
1119    #[test]
1120    fn test_dedup_nested_no_duplicates() {
1121        let javas = vec![
1122            JavaInfo {
1123                java_home: PathBuf::from("/usr/lib/jvm/java-11"),
1124                ..Default::default()
1125            },
1126            JavaInfo {
1127                java_home: PathBuf::from("/usr/lib/jvm/java-17"),
1128                ..Default::default()
1129            },
1130        ];
1131        let result = dedup_nested(javas);
1132        assert_eq!(result.len(), 2);
1133    }
1134
1135    #[test]
1136    fn test_dedup_nested_removes_jre() {
1137        let javas = vec![
1138            JavaInfo {
1139                java_home: PathBuf::from("/usr/lib/jvm/java-11"),
1140                ..Default::default()
1141            },
1142            JavaInfo {
1143                java_home: PathBuf::from("/usr/lib/jvm/java-11/jre"),
1144                ..Default::default()
1145            },
1146        ];
1147        let result = dedup_nested(javas);
1148        assert_eq!(result.len(), 1);
1149        assert_eq!(result[0].java_home, PathBuf::from("/usr/lib/jvm/java-11"));
1150    }
1151
1152    #[test]
1153    fn test_dedup_nested_same_path() {
1154        let javas = vec![
1155            JavaInfo {
1156                java_home: PathBuf::from("/usr/lib/jvm/java-11"),
1157                ..Default::default()
1158            },
1159            JavaInfo {
1160                java_home: PathBuf::from("/usr/lib/jvm/java-11"),
1161                ..Default::default()
1162            },
1163        ];
1164        let result = dedup_nested(javas);
1165        assert_eq!(result.len(), 0);
1166    }
1167
1168    #[test]
1169    fn test_dedup_nested_standalone_jre() {
1170        let javas = vec![
1171            JavaInfo {
1172                java_home: PathBuf::from("/usr/lib/jvm/java-11"),
1173                ..Default::default()
1174            },
1175            JavaInfo {
1176                java_home: PathBuf::from("/usr/lib/jvm/jre-8"),
1177                ..Default::default()
1178            },
1179        ];
1180        let result = dedup_nested(javas);
1181        assert_eq!(result.len(), 2);
1182    }
1183
1184    #[test]
1185    fn test_is_version_like_valid() {
1186        assert!(is_version_like("11.0.2"));
1187        assert!(is_version_like("1.8.0_202"));
1188        assert!(is_version_like("17_3"));
1189    }
1190
1191    #[test]
1192    fn test_is_version_like_invalid() {
1193        assert!(!is_version_like(""));
1194        assert!(!is_version_like(&"a".repeat(21)));
1195        assert!(!is_version_like("no_digits"));
1196    }
1197
1198    #[test]
1199    fn test_is_version_like_more() {
1200        assert!(is_version_like("8"));
1201        assert!(is_version_like("11.0"));
1202        assert!(is_version_like("1.8"));
1203        assert!(is_version_like("20.0.0.0"));
1204        assert!(is_version_like(".1"));
1205        assert!(!is_version_like(".."));
1206        assert!(!is_version_like("a-b-c"));
1207    }
1208
1209    #[test]
1210    fn test_dedup_nested_deeply_nested() {
1211        let javas = vec![
1212            JavaInfo {
1213                java_home: PathBuf::from("/usr/lib/jvm/java-11"),
1214                ..Default::default()
1215            },
1216            JavaInfo {
1217                java_home: PathBuf::from("/usr/lib/jvm/java-11/jre"),
1218                ..Default::default()
1219            },
1220            JavaInfo {
1221                java_home: PathBuf::from("/usr/lib/jvm/java-11/jre/lib"),
1222                ..Default::default()
1223            },
1224        ];
1225        let result = dedup_nested(javas);
1226        assert_eq!(result.len(), 1);
1227        assert_eq!(result[0].java_home, PathBuf::from("/usr/lib/jvm/java-11"));
1228    }
1229
1230    #[test]
1231    fn test_filter_by_version() {
1232        let javas = vec![
1233            JavaInfo {
1234                version: "11.0.2".into(),
1235                parsed_version: crate::JavaVersion::parse("11.0.2"),
1236                ..Default::default()
1237            },
1238            JavaInfo {
1239                version: "17.0.1".into(),
1240                parsed_version: crate::JavaVersion::parse("17.0.1"),
1241                ..Default::default()
1242            },
1243        ];
1244        let filtered = crate::filter_by_version(javas, "17");
1245        assert_eq!(filtered.len(), 1);
1246        assert_eq!(filtered[0].version, "17.0.1");
1247    }
1248
1249    #[test]
1250    fn test_best_match() {
1251        let javas = vec![
1252            JavaInfo {
1253                version: "11.0.2".into(),
1254                parsed_version: crate::JavaVersion::parse("11.0.2"),
1255                ..Default::default()
1256            },
1257            JavaInfo {
1258                version: "17.0.1".into(),
1259                parsed_version: crate::JavaVersion::parse("17.0.1"),
1260                ..Default::default()
1261            },
1262            JavaInfo {
1263                version: "8.0_202".into(),
1264                parsed_version: crate::JavaVersion::parse("1.8.0_202"),
1265                ..Default::default()
1266            },
1267        ];
1268        let best = crate::best_match(javas, "8");
1269        assert!(best.is_some());
1270        assert_eq!(best.unwrap().version, "8.0_202");
1271    }
1272
1273    #[test]
1274    fn test_filter_by_version_no_match() {
1275        let javas = vec![JavaInfo {
1276            version: "11.0.2".into(),
1277            parsed_version: crate::JavaVersion::parse("11.0.2"),
1278            ..Default::default()
1279        }];
1280        let filtered = crate::filter_by_version(javas, "17");
1281        assert!(filtered.is_empty());
1282    }
1283
1284    #[test]
1285    fn test_best_match_no_match() {
1286        let javas = vec![JavaInfo {
1287            version: "11.0.2".into(),
1288            parsed_version: crate::JavaVersion::parse("11.0.2"),
1289            ..Default::default()
1290        }];
1291        let best = crate::best_match(javas, "17");
1292        assert!(best.is_none());
1293    }
1294}
1295
1296// -----------------------------------------------------------------------------
1297// BFS scan of default paths (Windows)
1298// -----------------------------------------------------------------------------
1299#[cfg(target_os = "windows")]
1300fn scan_default_paths() -> Vec<PathBuf> {
1301    let mut results = Vec::new();
1302    let roots = default_path_roots();
1303
1304    for root in roots {
1305        if !root.exists() {
1306            debug!(
1307                "scan_default_paths: root does not exist: {}",
1308                root.display()
1309            );
1310            continue;
1311        }
1312
1313        debug!("scan_default_paths: starting BFS from {}", root.display());
1314        let mut queue = VecDeque::new();
1315        queue.push_back((root, 0));
1316
1317        while let Some((current, depth)) = queue.pop_front() {
1318            if depth > MAX_SEARCH_DEPTH {
1319                continue;
1320            }
1321
1322            if let Ok(entries) = fs::read_dir(&current) {
1323                for entry in entries.flatten() {
1324                    let path = entry.path();
1325                    if !path.is_dir() {
1326                        continue;
1327                    }
1328
1329                    let java_exe = path.join("java.exe");
1330                    if java_exe.exists() {
1331                        debug!("scan_default_paths: found Java at {}", path.display());
1332                        results.push(java_exe);
1333                        continue;
1334                    }
1335
1336                    if should_explore_deeper(&path) {
1337                        debug!(
1338                            "scan_default_paths: exploring {} (depth {})",
1339                            path.display(),
1340                            depth + 1
1341                        );
1342                        queue.push_back((path, depth + 1));
1343                    }
1344                }
1345            }
1346        }
1347    }
1348
1349    results
1350}
1351
1352#[cfg(target_os = "windows")]
1353fn default_path_roots() -> Vec<PathBuf> {
1354    let mut roots = Vec::new();
1355
1356    if let Some(appdata) = env::var_os("APPDATA") {
1357        roots.push(Path::new(&appdata).join(".minecraft").join("runtime"));
1358    }
1359
1360    if let Some(localappdata) = env::var_os("LOCALAPPDATA") {
1361        roots.push(Path::new(&localappdata).to_path_buf());
1362    }
1363
1364    if let Some(profile) = env::var_os("USERPROFILE") {
1365        roots.push(Path::new(&profile).to_path_buf());
1366    }
1367
1368    for drive in fixed_drives() {
1369        let prog_files = Path::new(&drive).join("Program Files");
1370        if prog_files.exists() {
1371            roots.push(prog_files);
1372        }
1373
1374        let prog_files_x86 = Path::new(&drive).join("Program Files (x86)");
1375        if prog_files_x86.exists() {
1376            roots.push(prog_files_x86);
1377        }
1378
1379        if let Ok(entries) = fs::read_dir(&drive) {
1380            for entry in entries.flatten() {
1381                let dir_name = entry.file_name().to_string_lossy().to_lowercase();
1382                if JAVA_KEYWORDS.iter().any(|kw| dir_name.contains(kw)) {
1383                    roots.push(entry.path());
1384                }
1385            }
1386        }
1387    }
1388
1389    roots
1390}
1391
1392#[cfg(target_os = "windows")]
1393fn fixed_drives() -> Vec<String> {
1394    let mut drives = Vec::new();
1395    for letter in 'A'..='Z' {
1396        let drive = format!("{letter}:\\");
1397        let p = Path::new(&drive);
1398        if p.exists() && fs::read_dir(p).is_ok() {
1399            drives.push(drive);
1400        }
1401    }
1402    drives
1403}
1404
1405#[cfg(target_os = "windows")]
1406fn should_explore_deeper(path: &Path) -> bool {
1407    let name = match path.file_name() {
1408        Some(n) => n.to_string_lossy(),
1409        None => return false,
1410    };
1411
1412    let lower = name.to_lowercase();
1413
1414    for ex in EXCLUDE_FOLDERS {
1415        if lower.contains(ex) {
1416            return false;
1417        }
1418    }
1419
1420    for kw in JAVA_KEYWORDS {
1421        if lower.contains(kw) {
1422            return true;
1423        }
1424    }
1425
1426    is_version_like(&name)
1427}
1428
1429fn is_version_like(name: &str) -> bool {
1430    if name.is_empty() || name.len() > 20 {
1431        return false;
1432    }
1433
1434    let has_digit = name.chars().any(|c| c.is_ascii_digit());
1435    if !has_digit {
1436        return false;
1437    }
1438
1439    name.chars()
1440        .all(|c| c.is_ascii_digit() || c == '.' || c == '_' || c == '-')
1441}
1442
1443#[cfg(target_os = "windows")]
1444fn scan_microsoft_store() -> Vec<PathBuf> {
1445    let localappdata = match env::var_os("LOCALAPPDATA") {
1446        Some(v) => v,
1447        None => return Vec::new(),
1448    };
1449
1450    let base = Path::new(&localappdata)
1451        .join(r"Packages\Microsoft.4297127D64EC6_8wekyb3d8bbwe\LocalCache\Local\runtime");
1452
1453    if !base.exists() {
1454        debug!("scan_microsoft_store: base path does not exist");
1455        return Vec::new();
1456    }
1457
1458    debug!("scan_microsoft_store: scanning {}", base.display());
1459    let mut results = Vec::new();
1460
1461    if let Ok(runtimes) = fs::read_dir(&base) {
1462        for runtime in runtimes.flatten() {
1463            let runtime_path = runtime.path();
1464            let name = runtime_path.file_name().map(|n| n.to_string_lossy());
1465            if !name
1466                .as_deref()
1467                .is_some_and(|n| n.starts_with("java-runtime"))
1468            {
1469                continue;
1470            }
1471
1472            if let Ok(archs) = fs::read_dir(&runtime_path) {
1473                for arch in archs.flatten() {
1474                    let arch_path = arch.path();
1475                    if !arch_path.is_dir() {
1476                        continue;
1477                    }
1478
1479                    if let Ok(versions) = fs::read_dir(&arch_path) {
1480                        for version in versions.flatten() {
1481                            let java_exe = version.path().join("bin").join("java.exe");
1482                            if java_exe.exists() {
1483                                results.push(java_exe);
1484                            }
1485                        }
1486                    }
1487                }
1488            }
1489        }
1490    }
1491
1492    results
1493}
1494
1495#[cfg(target_os = "windows")]
1496fn scan_where_command() -> Vec<PathBuf> {
1497    let mut results = Vec::new();
1498
1499    debug!("scan_where_command: running `where java`");
1500    if let Ok(output) = std::process::Command::new("where").arg("java").output()
1501        && output.status.success()
1502    {
1503        let stdout = String::from_utf8_lossy(&output.stdout);
1504        for line in stdout.lines() {
1505            let trimmed = line.trim();
1506            if !trimmed.is_empty() {
1507                let p = Path::new(trimmed);
1508                if p.exists() {
1509                    results.push(p.to_path_buf());
1510                }
1511            }
1512        }
1513    }
1514
1515    results
1516}
1517
1518#[cfg(target_os = "linux")]
1519fn scan_linux() -> Vec<PathBuf> {
1520    use walkdir::WalkDir;
1521
1522    let mut results = Vec::new();
1523
1524    let mut search_dirs: Vec<PathBuf> = vec![
1525        "/usr/lib/jvm".into(),
1526        "/usr/java".into(),
1527        "/opt".into(),
1528        "/usr/local".into(),
1529    ];
1530
1531    if let Ok(home) = env::var("HOME") {
1532        let mc_runtime = Path::new(&home).join(".minecraft").join("runtime");
1533        if mc_runtime.exists() {
1534            search_dirs.push(mc_runtime);
1535        }
1536    }
1537
1538    for dir in search_dirs {
1539        if !dir.exists() {
1540            debug!("scan_linux: directory does not exist: {}", dir.display());
1541            continue;
1542        }
1543
1544        debug!("scan_linux: walking {}", dir.display());
1545        for entry in WalkDir::new(&dir)
1546            .follow_links(true)
1547            .into_iter()
1548            .filter_map(|e| e.ok())
1549        {
1550            let entry_path = entry.path();
1551
1552            if entry_path.file_name() != Some(std::ffi::OsStr::new("java")) {
1553                continue;
1554            }
1555            if !entry_path.is_file() {
1556                continue;
1557            }
1558
1559            if let Some(parent) = entry_path.parent() {
1560                if !should_explore_linux(parent) {
1561                    continue;
1562                }
1563            }
1564
1565            #[cfg(unix)]
1566            {
1567                use std::os::unix::fs::PermissionsExt;
1568                if let Ok(metadata) = entry_path.metadata() {
1569                    let permissions = metadata.permissions();
1570                    if permissions.mode() & 0o111 != 0 {
1571                        debug!("scan_linux: found Java at {}", entry_path.display());
1572                        results.push(entry_path.to_path_buf());
1573                    }
1574                }
1575            }
1576            #[cfg(not(unix))]
1577            {
1578                results.push(entry_path.to_path_buf());
1579            }
1580        }
1581    }
1582
1583    results
1584}
1585
1586#[cfg(target_os = "linux")]
1587fn should_explore_linux(path: &Path) -> bool {
1588    let name = match path.file_name() {
1589        Some(n) => n.to_string_lossy(),
1590        None => return true,
1591    };
1592
1593    let lower = name.to_lowercase();
1594
1595    for ex in EXCLUDE_FOLDERS {
1596        if lower.contains(ex) {
1597            return false;
1598        }
1599    }
1600
1601    for kw in JAVA_KEYWORDS {
1602        if lower.contains(kw) {
1603            return true;
1604        }
1605    }
1606
1607    is_version_like(&name)
1608}
1609
1610#[cfg(target_os = "macos")]
1611fn scan_macos() -> Vec<PathBuf> {
1612    use walkdir::WalkDir;
1613
1614    let mut results = Vec::new();
1615
1616    let mut search_dirs: Vec<PathBuf> = vec!["/Library/Java/JavaVirtualMachines".into()];
1617
1618    if let Ok(home) = env::var("HOME") {
1619        let user_jvm = Path::new(&home).join("Library/Java/JavaVirtualMachines");
1620        if user_jvm.exists() {
1621            search_dirs.push(user_jvm);
1622        }
1623
1624        let mc_runtime = Path::new(&home).join(".minecraft").join("runtime");
1625        if mc_runtime.exists() {
1626            search_dirs.push(mc_runtime);
1627        }
1628    }
1629
1630    // Also try /usr/libexec/java_home for the system default
1631    debug!("scan_macos: running /usr/libexec/java_home");
1632    if let Ok(output) = std::process::Command::new("/usr/libexec/java_home").output() {
1633        if output.status.success() {
1634            let path_str = String::from_utf8_lossy(&output.stdout);
1635            let jh_path = Path::new(path_str.trim());
1636            if jh_path.exists() {
1637                let java_exe = jh_path.join("bin").join("java");
1638                if java_exe.exists() {
1639                    debug!(
1640                        "scan_macos: /usr/libexec/java_home -> {}",
1641                        java_exe.display()
1642                    );
1643                    results.push(java_exe);
1644                }
1645            }
1646        }
1647    }
1648
1649    for dir in search_dirs {
1650        if !dir.exists() {
1651            debug!("scan_macos: directory does not exist: {}", dir.display());
1652            continue;
1653        }
1654
1655        debug!("scan_macos: walking {}", dir.display());
1656        for entry in WalkDir::new(&dir)
1657            .max_depth(5)
1658            .follow_links(true)
1659            .into_iter()
1660            .filter_map(|e| e.ok())
1661        {
1662            let entry_path = entry.path();
1663
1664            if entry_path.file_name() != Some(std::ffi::OsStr::new("java")) {
1665                continue;
1666            }
1667            if !entry_path.is_file() {
1668                continue;
1669            }
1670
1671            #[cfg(unix)]
1672            {
1673                use std::os::unix::fs::PermissionsExt;
1674                if let Ok(metadata) = entry_path.metadata() {
1675                    let permissions = metadata.permissions();
1676                    if permissions.mode() & 0o111 != 0 {
1677                        debug!("scan_macos: found Java at {}", entry_path.display());
1678                        results.push(entry_path.to_path_buf());
1679                    }
1680                }
1681            }
1682            #[cfg(not(unix))]
1683            {
1684                results.push(entry_path.to_path_buf());
1685            }
1686        }
1687    }
1688
1689    results
1690}