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;
use std::process::Command;
use std::sync::OnceLock;

#[derive(Debug, Clone, Default)]
pub struct PackageInfo {
    pub name: String,
    pub installed_version: Option<String>,
    pub candidate_version: Option<String>,
    pub installed_size_kb: Option<u64>,
    pub download_size_bytes: Option<u64>,
    pub description: Option<String>,
    pub section: Option<String>,
    pub homepage: Option<String>,
    pub depends: Vec<String>,
}

static SIZE_RE: OnceLock<Regex> = OnceLock::new();

fn size_re() -> &'static Regex {
    SIZE_RE.get_or_init(|| Regex::new(r"(\d+)").unwrap())
}

fn parse_apt_cache_show(output: &str, name: &str) -> PackageInfo {
    let mut info = PackageInfo {
        name: name.to_string(),
        ..Default::default()
    };

    let fields: HashMap<&str, &str> = output
        .lines()
        .filter_map(|l| {
            let mut parts = l.splitn(2, ": ");
            let key = parts.next()?.trim();
            let val = parts.next()?.trim();
            Some((key, val))
        })
        .collect();

    info.description = fields.get("Description").map(|s| s.to_string());
    info.section = fields.get("Section").map(|s| s.to_string());
    info.homepage = fields.get("Homepage").map(|s| s.to_string());
    info.depends = fields
        .get("Depends")
        .map(|d| d.split(',').map(|s| s.trim().to_string()).collect())
        .unwrap_or_default();

    if let Some(size_str) = fields.get("Installed-Size") {
        info.installed_size_kb = size_str.parse().ok();
    }
    if let Some(size_str) = fields.get("Size") {
        info.download_size_bytes = size_str.parse().ok();
    }

    info
}

fn parse_apt_cache_policy(output: &str) -> (Option<String>, Option<String>) {
    let mut installed = None;
    let mut candidate = None;

    for line in output.lines() {
        let trimmed = line.trim();
        if let Some(v) = trimmed.strip_prefix("Installed: ") {
            if v != "(none)" {
                installed = Some(v.to_string());
            }
        } else if let Some(v) = trimmed.strip_prefix("Candidate: ") {
            if v != "(none)" {
                candidate = Some(v.to_string());
            }
        }
    }

    (installed, candidate)
}

pub fn show_package(name: &str) -> Result<PackageInfo> {
    let show_out = Command::new("apt-cache")
        .args(["show", name])
        .output()?;
    let policy_out = Command::new("apt-cache")
        .args(["policy", name])
        .output()?;

    let show_str = String::from_utf8_lossy(&show_out.stdout);
    let policy_str = String::from_utf8_lossy(&policy_out.stdout);

    let mut info = parse_apt_cache_show(&show_str, name);
    let (installed, candidate) = parse_apt_cache_policy(&policy_str);
    info.installed_version = installed;
    info.candidate_version = candidate;

    Ok(info)
}

pub fn search_packages(term: &str, names_only: bool) -> Result<Vec<PackageInfo>> {
    let mut cmd = Command::new("apt-cache");
    cmd.arg("search");
    if names_only {
        cmd.arg("--names-only");
    }
    cmd.arg(term);

    let out = cmd.output()?;
    let stdout = String::from_utf8_lossy(&out.stdout);

    let mut results = Vec::new();
    for line in stdout.lines() {
        let mut parts = line.splitn(2, " - ");
        let name = parts.next().unwrap_or("").trim().to_string();
        let desc = parts.next().map(|s| s.to_string());
        if !name.is_empty() {
            results.push(PackageInfo {
                name,
                description: desc,
                ..Default::default()
            });
        }
    }

    Ok(results)
}

pub fn list_packages(installed: bool, upgradable: bool) -> Result<Vec<PackageInfo>> {
    let mut args = vec!["list"];
    if installed {
        args.push("--installed");
    }
    if upgradable {
        args.push("--upgradable");
    }

    let out = Command::new("apt").args(&args).output()?;
    let stdout = String::from_utf8_lossy(&out.stdout);

    // Format: "package/suite,suite ver arch [installed[,upgradable to ver]]"
    let re = Regex::new(r"^([^/]+)/\S+\s+(\S+)\s+\S+(?:\s+\[(.+)\])?").unwrap();

    let mut packages = Vec::new();
    for line in stdout.lines().skip(1) {
        // skip WARNING line
        if line.starts_with("WARNING") || line.is_empty() {
            continue;
        }
        if let Some(caps) = re.captures(line) {
            let name = caps[1].to_string();
            let ver = caps[2].to_string();
            let status = caps.get(3).map(|m| m.as_str());

            let (installed_ver, candidate_ver) = if status.map_or(false, |s| s.starts_with("installed")) {
                if let Some(upg) = status.and_then(|s| s.strip_prefix("installed,upgradable to ")) {
                    (Some(ver.clone()), Some(upg.to_string()))
                } else {
                    (Some(ver.clone()), None)
                }
            } else {
                (None, Some(ver))
            };

            packages.push(PackageInfo {
                name,
                installed_version: installed_ver,
                candidate_version: candidate_ver,
                ..Default::default()
            });
        }
    }

    // Suppress unused warning
    let _ = size_re();

    Ok(packages)
}