rebecca-windows 0.3.0

Windows-specific adapters for Rebecca discovery and host capabilities.
Documentation
#[cfg(windows)]
use std::collections::BTreeSet;
#[cfg(windows)]
use std::path::{Path, PathBuf};

use rebecca_core::applications::InstalledApplication;
#[cfg(windows)]
use rebecca_core::error::RebeccaError;
use rebecca_core::error::Result;

#[cfg(windows)]
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ};
#[cfg(windows)]
use winreg::{HKEY, RegKey};

#[cfg(windows)]
#[derive(Clone, Copy)]
struct UninstallRegistryRoot {
    root: HKEY,
    key_path: &'static str,
    root_label: &'static str,
}

#[cfg(windows)]
const UNINSTALL_REGISTRY_ROOTS: [UninstallRegistryRoot; 4] = [
    UninstallRegistryRoot {
        root: HKEY_CURRENT_USER,
        key_path: "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
        root_label: "hkcu",
    },
    UninstallRegistryRoot {
        root: HKEY_LOCAL_MACHINE,
        key_path: "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
        root_label: "hklm",
    },
    UninstallRegistryRoot {
        root: HKEY_LOCAL_MACHINE,
        key_path: "SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
        root_label: "hklm-wow6432",
    },
    UninstallRegistryRoot {
        root: HKEY_CURRENT_USER,
        key_path: "Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
        root_label: "hkcu-wow6432",
    },
];

#[cfg(windows)]
#[derive(Debug, Clone, Default)]
struct RawUninstallEntry {
    key_name: String,
    display_name: Option<String>,
    publisher: Option<String>,
    install_location: Option<String>,
    display_icon: Option<String>,
    uninstall_string: Option<String>,
    system_component: Option<u32>,
}

pub fn discover_installed_applications() -> Result<Vec<InstalledApplication>> {
    platform::discover_installed_applications()
}

#[cfg(windows)]
mod platform {
    use super::*;

    pub fn discover_installed_applications() -> Result<Vec<InstalledApplication>> {
        discover_installed_applications_with(read_uninstall_entries)
    }

    fn read_uninstall_entries(
        source: UninstallRegistryRoot,
    ) -> Result<Vec<(String, RawUninstallEntry)>> {
        let root =
            match RegKey::predef(source.root).open_subkey_with_flags(source.key_path, KEY_READ) {
                Ok(root) => root,
                Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
                Err(err) => {
                    return Err(RebeccaError::ApplicationDiscoveryFailed(format!(
                        "could not open uninstall registry root {}: {err}",
                        source.key_path
                    )));
                }
            };

        let mut entries = Vec::new();
        for key_name in root.enum_keys().filter_map(std::result::Result::ok) {
            let Ok(key) = root.open_subkey_with_flags(&key_name, KEY_READ) else {
                continue;
            };

            entries.push((
                source.root_label.to_string(),
                RawUninstallEntry {
                    display_name: registry_string(&key, "DisplayName"),
                    publisher: registry_string(&key, "Publisher"),
                    install_location: registry_string(&key, "InstallLocation"),
                    display_icon: registry_string(&key, "DisplayIcon"),
                    uninstall_string: registry_string(&key, "UninstallString"),
                    system_component: registry_u32(&key, "SystemComponent"),
                    key_name,
                },
            ));
        }

        Ok(entries)
    }

    fn registry_string(key: &RegKey, value_name: &str) -> Option<String> {
        key.get_value::<String, _>(value_name)
            .ok()
            .map(|value| value.trim().to_string())
            .filter(|value| !value.is_empty())
    }

    fn registry_u32(key: &RegKey, value_name: &str) -> Option<u32> {
        key.get_value::<u32, _>(value_name).ok()
    }
}

#[cfg(not(windows))]
mod platform {
    use super::*;

    pub fn discover_installed_applications() -> Result<Vec<InstalledApplication>> {
        Ok(Vec::new())
    }
}

#[cfg(windows)]
fn discover_installed_applications_with<F>(mut read_entries: F) -> Result<Vec<InstalledApplication>>
where
    F: FnMut(UninstallRegistryRoot) -> Result<Vec<(String, RawUninstallEntry)>>,
{
    let mut apps = Vec::new();

    for source in UNINSTALL_REGISTRY_ROOTS {
        let Ok(entries) = read_entries(source) else {
            continue;
        };
        apps.extend(entries.into_iter().filter_map(|(root_label, entry)| {
            application_from_uninstall_entry(&root_label, source.key_path, entry)
        }));
    }

    Ok(dedupe_applications(apps))
}

#[cfg(windows)]
fn application_from_uninstall_entry(
    root_label: &str,
    source_path: &str,
    entry: RawUninstallEntry,
) -> Option<InstalledApplication> {
    if entry.system_component == Some(1) {
        return None;
    }

    let display_name = entry.display_name?.trim().to_string();
    if display_name.is_empty() {
        return None;
    }

    let mut install_locations = Vec::new();
    if let Some(path) = entry
        .install_location
        .as_deref()
        .and_then(install_location_from_value)
    {
        install_locations.push(path);
    }
    if let Some(path) = entry
        .display_icon
        .as_deref()
        .and_then(install_root_from_command_like_value)
    {
        install_locations.push(path);
    }
    if let Some(path) = entry
        .uninstall_string
        .as_deref()
        .and_then(install_root_from_command_like_value)
    {
        install_locations.push(path);
    }

    let stable_id = format!(
        "{root_label}:{}:{}",
        source_path.to_ascii_lowercase(),
        entry.key_name.to_ascii_lowercase()
    );

    let mut app = InstalledApplication::new(stable_id, display_name, install_locations);
    if let Some(publisher) = entry.publisher {
        app = app.with_publisher(publisher);
    }

    Some(app)
}

#[cfg(windows)]
fn install_location_from_value(value: &str) -> Option<PathBuf> {
    let trimmed = value.trim().trim_matches('"');
    if trimmed.is_empty() {
        return None;
    }

    Some(PathBuf::from(trimmed))
}

#[cfg(windows)]
fn install_root_from_command_like_value(value: &str) -> Option<PathBuf> {
    let executable = extract_executable_path(value)?;
    Path::new(&executable).parent().map(PathBuf::from)
}

#[cfg(windows)]
fn extract_executable_path(value: &str) -> Option<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return None;
    }

    let candidate = if let Some(rest) = trimmed.strip_prefix('"') {
        rest.split_once('"').map(|(path, _)| path.to_string())?
    } else {
        let lower = trimmed.to_ascii_lowercase();
        let mut search_start = 0;
        loop {
            let relative_end = lower[search_start..].find(".exe")?;
            let exe_end = search_start + relative_end + 4;
            let next_char = trimmed[exe_end..].chars().next();
            if next_char.is_none()
                || next_char.is_some_and(|ch| ch.is_whitespace() || ch == '"' || ch == ',')
            {
                break trimmed[..exe_end].trim().trim_matches(',').to_string();
            }
            search_start = exe_end;
        }
    };

    if candidate.trim().is_empty() {
        None
    } else {
        Some(candidate)
    }
}

#[cfg(windows)]
fn dedupe_applications(
    applications: impl IntoIterator<Item = InstalledApplication>,
) -> Vec<InstalledApplication> {
    let mut seen = BTreeSet::new();
    let mut deduped = Vec::new();

    for app in applications {
        let key = application_key(&app);
        if seen.insert(key) {
            deduped.push(app);
        }
    }

    deduped.sort_by(|left, right| {
        left.display_name()
            .to_ascii_lowercase()
            .cmp(&right.display_name().to_ascii_lowercase())
            .then_with(|| left.stable_id().cmp(right.stable_id()))
    });
    deduped
}

#[cfg(windows)]
fn application_key(app: &InstalledApplication) -> String {
    if let Some(first_location) = app.install_locations().first() {
        return format!(
            "{}|{}",
            app.display_name().trim().to_ascii_lowercase(),
            path_key(first_location)
        );
    }

    app.stable_id().trim().to_ascii_lowercase()
}

#[cfg(windows)]
fn path_key(path: &Path) -> String {
    let mut normalized = path.as_os_str().to_string_lossy().replace('\\', "/");
    while normalized.ends_with('/') && normalized.len() > 3 {
        normalized.pop();
    }
    normalized.to_ascii_lowercase()
}

#[cfg(all(test, windows))]
mod tests {
    use super::*;

    #[test]
    fn command_like_value_extracts_quoted_executable_root() {
        let path =
            install_root_from_command_like_value(r#""C:\Program Files\Example\uninstall.exe" /S"#);

        assert_eq!(path, Some(PathBuf::from(r"C:\Program Files\Example")));
    }

    #[test]
    fn command_like_value_extracts_unquoted_executable_root() {
        let path =
            install_root_from_command_like_value(r"C:\Program Files\Example\uninstall.exe /S");

        assert_eq!(path, Some(PathBuf::from(r"C:\Program Files\Example")));
    }

    #[test]
    fn uninstall_entry_with_display_name_and_install_location_becomes_application() {
        let app = application_from_uninstall_entry(
            "hklm",
            r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
            RawUninstallEntry {
                key_name: "Example".to_string(),
                display_name: Some("Example App".to_string()),
                publisher: Some("Example Inc".to_string()),
                install_location: Some(r"C:\Program Files\Example".to_string()),
                ..RawUninstallEntry::default()
            },
        )
        .expect("entry should be discovered");

        assert_eq!(app.display_name(), "Example App");
        assert_eq!(app.publisher.as_deref(), Some("Example Inc"));
        assert_eq!(
            app.install_locations(),
            &[PathBuf::from(r"C:\Program Files\Example")]
        );
    }

    #[test]
    fn uninstall_entry_skips_system_component_and_missing_display_name() {
        assert!(
            application_from_uninstall_entry(
                "hklm",
                "path",
                RawUninstallEntry {
                    key_name: "System".to_string(),
                    display_name: Some("System".to_string()),
                    system_component: Some(1),
                    ..RawUninstallEntry::default()
                },
            )
            .is_none()
        );
        assert!(
            application_from_uninstall_entry(
                "hklm",
                "path",
                RawUninstallEntry {
                    key_name: "Missing".to_string(),
                    ..RawUninstallEntry::default()
                },
            )
            .is_none()
        );
    }

    #[test]
    fn uninstall_inventory_skips_failed_sources_without_failing_run() {
        let mut calls = 0usize;
        let apps = discover_installed_applications_with(|source| {
            calls += 1;
            if calls == 1 {
                return Err(RebeccaError::ApplicationDiscoveryFailed(
                    "source unavailable".to_string(),
                ));
            }

            Ok(vec![(
                source.root_label.to_string(),
                RawUninstallEntry {
                    key_name: "Example".to_string(),
                    display_name: Some("Example App".to_string()),
                    install_location: Some(r"C:\Program Files\Example".to_string()),
                    ..RawUninstallEntry::default()
                },
            )])
        })
        .unwrap();

        assert_eq!(apps.len(), 1);
        assert_eq!(apps[0].display_name(), "Example App");
    }
}