rustpm 0.1.0

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

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,
}

static PKG_RE: OnceLock<Regex> = OnceLock::new();
static AVAIL_RE: OnceLock<Regex> = OnceLock::new();

fn pkg_re() -> &'static Regex {
    PKG_RE.get_or_init(|| {
        Regex::new(r"^linux-image-(\d+\.\d+[^\s]+-amd64)\s").unwrap()
    })
}

fn avail_re() -> &'static Regex {
    AVAIL_RE.get_or_init(|| {
        // Match concrete versioned kernel images, not meta-packages or dbg/unsigned/rt/cloud variants
        Regex::new(r"^linux-image-(\d+\.\d+[^\s]+-amd64)\s").unwrap()
    })
}

/// Return the running kernel version string (e.g. "6.12.88+deb13-amd64").
pub fn running_kernel() -> String {
    std::process::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> {
    // Returns map: version_string -> apt package version
    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() {
        // Skip debug, unsigned, rt, cloud variants
        if line.contains("-dbg ")
            || line.contains("-unsigned ")
            || line.contains("-rt-")
            || line.contains("-cloud-")
        {
            continue;
        }

        if let Some(caps) = avail_re().captures(line) {
            let version = caps[1].to_string();
            let pkg_name = format!("linux-image-{}", version);

            // Get the apt version via policy
            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, 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();

    // Collect from installed images
    for pkg in &installed_images {
        if let Some(caps) = pkg_re().captures(&format!("{} ", pkg)) {
            versions.insert(caps[1].to_string());
        }
    }

    // Collect from available apt packages
    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);
            let installed = installed_images.contains(&image_pkg);
            let headers_installed = installed_headers.contains(&headers_pkg);
            let is_running = version == running;
            let available_in_apt = available.contains_key(&version);
            let apt_version = available.get(&version).cloned();
            let is_held = held.contains(&image_pkg);

            KernelEntry {
                version,
                image_package: image_pkg,
                headers_package: headers_pkg,
                installed,
                headers_installed,
                is_running,
                available_in_apt,
                apt_version,
                is_held,
            }
        })
        .collect();

    // Sort: running first, then installed, then by version descending
    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)
}