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