wanderlust 0.2.5

A powerful Windows PATH cleaner and healer. Automatically discovers tools, removes duplicates, and ensures system stability.
//! Crawls registry, common locations, and existing PATH to find tools.

use log::debug;
use std::collections::HashMap;
use std::path::PathBuf;
use walkdir::WalkDir;
use windows_registry::{CURRENT_USER, LOCAL_MACHINE};

#[derive(Debug, Clone)]
pub struct Candidate {
    pub path: PathBuf,
    #[doc(hidden)]
    pub _source: String,
}

use crate::invariant_ppt::assert_invariant;

pub fn discover_candidates() -> HashMap<String, Vec<Candidate>> {
    let mut map: HashMap<String, Vec<Candidate>> = HashMap::new();

    // 1. Scan Registry for installed programs
    scan_registry_uninstall(&mut map);

    // 2. Scan Common Locations (heuristic)
    scan_common_locations(&mut map);

    // 3. Scan existing PATH (to not lose what we already have, just clean it)
    scan_existing_path(&mut map);

    // INVARIANT: We must have discovered *something*. An empty map implies a broken system or logic.
    assert_invariant(
        !map.is_empty(),
        "Discovery phase yielded zero candidates. System appears to be empty or unreadable.",
        Some("Discovery"),
    );

    // INVARIANT: All keys must be lowercase to ensure case-insensitive matching logic holds.
    for key in map.keys() {
        assert_invariant(
            key == &key.to_lowercase(),
            &format!("Discovery key '{}' is not lowercase", key),
            Some("Discovery"),
        );
    }

    map
}

fn scan_registry_uninstall(map: &mut HashMap<String, Vec<Candidate>>) {
    let key_path = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";

    // Check both HKCU (Current User) and HKLM (Local Machine / System-wide)
    let hives = [
        (CURRENT_USER, "HKCU_Uninstall"),
        (LOCAL_MACHINE, "HKLM_Uninstall"),
    ];

    for (hive, source_label) in hives {
        if let Ok(uninstall_key) = hive.open(key_path) {
            for subkey_name in uninstall_key.keys().into_iter().flatten() {
                if let Ok(subkey) = uninstall_key.open(&subkey_name) {
                    // Try "InstallLocation"
                    if let Some(install_loc) = subkey
                        .get_string("InstallLocation")
                        .ok()
                        .filter(|s| !s.is_empty())
                    {
                        let path = PathBuf::from(&install_loc);
                        // Heuristic: check if there's a 'bin' folder, otherwise use root
                        let bin_path = path.join("bin");
                        if bin_path.exists() {
                            add_dir_candidates(map, &bin_path, source_label);
                        } else if path.exists() {
                            add_dir_candidates(map, &path, source_label);
                        }
                    }
                }
            }
        }
    }
}

fn scan_common_locations(map: &mut HashMap<String, Vec<Candidate>>) {
    if let Some(user_profile) = directories::UserDirs::new() {
        let home = user_profile.home_dir();

        // Cargo
        let cargo_bin = home.join(".cargo").join("bin");
        if cargo_bin.exists() {
            add_dir_candidates(map, &cargo_bin, "cargo");
        }

        // Local bin
        let local_bin = home.join(".local").join("bin");
        if local_bin.exists() {
            add_dir_candidates(map, &local_bin, "local_bin");
        }

        // Scoop shims
        let scoop_shims = home.join("scoop").join("shims");
        if scoop_shims.exists() {
            add_dir_candidates(map, &scoop_shims, "scoop");
        }

        // Python installations (Windows Store and python.org installer)
        // User installs go to: %LOCALAPPDATA%\Programs\Python\Python3XX\
        let python_base = home
            .join("AppData")
            .join("Local")
            .join("Programs")
            .join("Python");
        let entries = if python_base.exists() {
            std::fs::read_dir(&python_base).ok()
        } else {
            None
        };
        if let Some(entries) = entries {
            for entry in entries.filter_map(|e| e.ok()) {
                let path = entry.path();
                if path.is_dir() {
                    let name = path.file_name().unwrap_or_default().to_string_lossy();
                        // Match Python3XX directories (not Launcher)
                        if name.starts_with("Python3") {
                            add_dir_candidates(map, &path, "python");
                            // Also add Scripts subdirectory (pip, etc.)
                            let scripts = path.join("Scripts");
                            if scripts.exists() {
                                add_dir_candidates(map, &scripts, "python_scripts");
                            }
                        }
                    }
                }
            }
        }

    // System-wide Python installations
    let drives: Vec<String> = ('A'..='Z')
        .map(|d| format!("{}:", d))
        .filter(|d| std::path::Path::new(&format!("{}\\", d)).exists())
        .collect();
    for drive in drives {
        // Old-style: C:\Python3XX
        let drive_path = PathBuf::from(drive);
        if let Ok(entries) = std::fs::read_dir(&drive_path) {
            for entry in entries.filter_map(|e| e.ok()) {
                let path = entry.path();
                if path.is_dir() {
                    let name = path.file_name().unwrap_or_default().to_string_lossy();
                    if name.starts_with("Python3") {
                        add_dir_candidates(map, &path, "python_system");
                        let scripts = path.join("Scripts");
                        if scripts.exists() {
                            add_dir_candidates(map, &scripts, "python_scripts");
                        }
                    }
                }
            }
        }

        // Program Files style: C:\Program Files\Python3XX
        for pf in ["Program Files", "Program Files (x86)"] {
            let pf_path = drive_path.join(pf);
            if let Ok(entries) = std::fs::read_dir(&pf_path) {
                for entry in entries.filter_map(|e| e.ok()) {
                    let path = entry.path();
                    if path.is_dir() {
                        let name = path.file_name().unwrap_or_default().to_string_lossy();
                        if name.starts_with("Python3") {
                            add_dir_candidates(map, &path, "python_system");
                            let scripts = path.join("Scripts");
                            if scripts.exists() {
                                add_dir_candidates(map, &scripts, "python_scripts");
                            }
                        }
                    }
                }
            }
        }
    }
}

fn scan_existing_path(map: &mut HashMap<String, Vec<Candidate>>) {
    if let Ok(path_var) = std::env::var("PATH") {
        for part in path_var.split(';') {
            if part.is_empty() {
                continue;
            }
            let path = PathBuf::from(part);
            if path.exists() {
                add_dir_candidates(map, &path, "existing_path");
            }
        }
    }
}

fn add_dir_candidates(map: &mut HashMap<String, Vec<Candidate>>, dir: &PathBuf, source: &str) {
    debug!("Scanning directory: {:?}", dir);
    // Only go 1 level deep
    let walker = WalkDir::new(dir).max_depth(1);

    for entry in walker.into_iter().filter_map(|e| e.ok()) {
        let path = entry.path();
        if !path.is_file() {
            continue;
        }

        if let (Some(stem), Some(ext)) = (path.file_stem(), path.extension()) {
            let ext_str = ext.to_string_lossy().to_lowercase();
            // We only care about executables for Windows
            if ext_str == "exe" || ext_str == "cmd" || ext_str == "bat" || ext_str == "com" {
                let cmd_name = stem.to_string_lossy().to_lowercase();

                // Add to map
                map.entry(cmd_name).or_default().push(Candidate {
                    path: dir.to_path_buf(), // Store the *directory* containing the tool
                    _source: source.to_string(),
                });
            }
        }
    }
}