rustpm 0.2.6

A fast, friendly APT frontend with kernel, desktop, and sources management
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::process::Command;

fn run_stdout(program: &str, args: &[&str]) -> Vec<u8> {
    Command::new(program).args(args).output()
        .map(|o| o.stdout).unwrap_or_default()
}

#[derive(Debug, Clone)]
pub struct KernelEntry {
    pub version: String,
    pub image_package: String,
    pub headers_package: String,
    pub installed: bool,
    pub headers_installed: bool,
    pub is_running: bool,
    pub available_in_apt: bool,
    pub apt_version: Option<String>,
    pub is_held: bool,
}

/// Extract the kernel version string from a package name like `linux-image-6.12.88+deb13-amd64`.
/// Returns `Some("6.12.88+deb13-amd64")` or `None` if it doesn't match the pattern.
fn kernel_version_from_pkg(pkg: &str) -> Option<&str> {
    let rest = pkg.strip_prefix("linux-image-")?;
    // Must start with a digit and end with -amd64
    if rest.ends_with("-amd64") && rest.starts_with(|c: char| c.is_ascii_digit()) {
        Some(rest)
    } else {
        None
    }
}

pub fn running_kernel() -> String {
    Command::new("uname").arg("-r").output()
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
        .unwrap_or_default()
}

fn dpkg_installed_packages(glob: &str) -> HashSet<String> {
    let stdout = run_stdout("dpkg-query", &["-f", "${Package}\\t${db:Status-Abbrev}\\n", "-W", glob]);
    String::from_utf8_lossy(&stdout).lines().filter_map(|line| {
        let mut parts = line.splitn(2, '\t');
        let pkg = parts.next()?.to_string();
        let status = parts.next().unwrap_or("").trim();
        if status.starts_with("ii") { Some(pkg) } else { None }
    }).collect()
}

fn apt_held_packages() -> HashSet<String> {
    let stdout = run_stdout("apt-mark", &["showhold"]);
    String::from_utf8_lossy(&stdout).lines().map(|l| l.to_string()).collect()
}

fn apt_available_kernels() -> HashMap<String, String> {
    let raw = run_stdout("apt-cache", &["search", "linux-image-"]);
    let stdout = String::from_utf8_lossy(&raw).into_owned();
    let mut map = HashMap::new();

    for line in stdout.lines() {
        if line.contains("-dbg ") || line.contains("-unsigned ")
            || line.contains("-rt-") || line.contains("-cloud-") {
            continue;
        }
        let pkg_name = line.split_ascii_whitespace().next().unwrap_or("");
        if let Some(version) = kernel_version_from_pkg(pkg_name) {
            let policy_raw = run_stdout("apt-cache", &["policy", pkg_name]);
            let policy_str = String::from_utf8_lossy(&policy_raw).into_owned();
            let apt_ver = policy_str.lines().find_map(|l| {
                l.trim().strip_prefix("Candidate: ").map(|v| v.to_string())
            });
            map.insert(version.to_string(), apt_ver.unwrap_or_default());
        }
    }

    map
}

pub fn detect_kernels() -> Result<Vec<KernelEntry>> {
    let running          = running_kernel();
    let installed_images = dpkg_installed_packages("linux-image-*");
    let installed_headers = dpkg_installed_packages("linux-headers-*");
    let held             = apt_held_packages();
    let available        = apt_available_kernels();

    let mut versions: HashSet<String> = HashSet::new();

    for pkg in &installed_images {
        if let Some(ver) = kernel_version_from_pkg(pkg) {
            versions.insert(ver.to_string());
        }
    }
    for ver in available.keys() {
        versions.insert(ver.clone());
    }

    let mut entries: Vec<KernelEntry> = versions.into_iter().map(|version| {
        let image_pkg   = format!("linux-image-{}", version);
        let headers_pkg = format!("linux-headers-{}", version);
        KernelEntry {
            installed:        installed_images.contains(&image_pkg),
            headers_installed: installed_headers.contains(&headers_pkg),
            is_running:       version == running,
            available_in_apt: available.contains_key(&version),
            apt_version:      available.get(&version).cloned(),
            is_held:          held.contains(&image_pkg),
            image_package:    image_pkg,
            headers_package:  headers_pkg,
            version,
        }
    }).collect();

    entries.sort_by(|a, b| {
        b.is_running.cmp(&a.is_running)
            .then(b.installed.cmp(&a.installed))
            .then(b.version.cmp(&a.version))
    });

    Ok(entries)
}