waxpkg 0.20.0

Fast Homebrew-compatible package manager
use crate::error::{Result, WaxError};
use crate::package_spec::Ecosystem;
use crate::ui::dirs;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowsPackageManifest {
    pub ecosystem: Ecosystem,
    pub id: String,
    pub version: String,
    pub source: String,
    pub staging_dir: PathBuf,
    pub bin_links: Vec<PathBuf>,
    pub files: Vec<PathBuf>,
    #[serde(default)]
    pub install_kind: WindowsInstallKind,
    #[serde(default)]
    pub native_uninstall: Option<WindowsNativeUninstall>,
    pub installed_at: i64,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum WindowsInstallKind {
    #[default]
    Portable,
    Native,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WindowsNativeUninstall {
    pub command: String,
    pub args: Vec<String>,
}

pub fn wax_windows_root() -> Result<PathBuf> {
    Ok(dirs::home_dir()?.join(".local").join("wax"))
}

pub fn wax_bin_dir() -> Result<PathBuf> {
    Ok(wax_windows_root()?.join("bin"))
}

fn manifest_dir() -> Result<PathBuf> {
    Ok(wax_windows_root()?.join("windows").join("manifests"))
}

fn manifest_path(ecosystem: Ecosystem, id: &str) -> Result<PathBuf> {
    let safe_id: String = id
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
                c
            } else {
                '-'
            }
        })
        .collect();
    Ok(manifest_dir()?.join(format!("{}-{}.json", ecosystem.label(), safe_id)))
}

fn path_is_under_root(path: &Path, root: &Path) -> bool {
    path.starts_with(root)
}

pub fn collect_files(root: &Path) -> Result<Vec<PathBuf>> {
    let mut files = Vec::new();
    collect_files_inner(root, &mut files)?;
    files.sort();
    Ok(files)
}

fn collect_files_inner(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
    if !path.exists() {
        return Ok(());
    }
    for entry in std::fs::read_dir(path)? {
        let entry = entry?;
        let file_type = entry.file_type()?;
        let path = entry.path();
        if file_type.is_dir() {
            collect_files_inner(&path, files)?;
        } else if file_type.is_file() || file_type.is_symlink() {
            files.push(path);
        }
    }
    Ok(())
}

impl WindowsPackageManifest {
    pub fn new(
        ecosystem: Ecosystem,
        id: impl Into<String>,
        version: impl Into<String>,
        source: impl Into<String>,
        staging_dir: PathBuf,
        bin_links: Vec<PathBuf>,
        files: Vec<PathBuf>,
    ) -> Self {
        Self {
            ecosystem,
            id: id.into(),
            version: version.into(),
            source: source.into(),
            staging_dir,
            bin_links,
            files,
            install_kind: WindowsInstallKind::Portable,
            native_uninstall: None,
            installed_at: std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs() as i64,
        }
    }

    pub fn with_native_uninstall(mut self, uninstall: WindowsNativeUninstall) -> Self {
        self.install_kind = WindowsInstallKind::Native;
        self.native_uninstall = Some(uninstall);
        self
    }

    pub fn save(&self) -> Result<()> {
        let path = manifest_path(self.ecosystem, &self.id)?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let raw = serde_json::to_string_pretty(self).map_err(WaxError::JsonError)?;
        std::fs::write(path, raw)?;
        Ok(())
    }
}

pub fn load_manifest(ecosystem: Ecosystem, id: &str) -> Result<Option<WindowsPackageManifest>> {
    let path = manifest_path(ecosystem, id)?;
    if !path.exists() {
        return Ok(None);
    }
    let raw = std::fs::read_to_string(path)?;
    Ok(Some(
        serde_json::from_str(&raw).map_err(WaxError::JsonError)?,
    ))
}

pub fn list_manifests() -> Result<Vec<WindowsPackageManifest>> {
    let dir = manifest_dir()?;
    if !dir.exists() {
        return Ok(Vec::new());
    }
    let mut manifests: Vec<WindowsPackageManifest> = Vec::new();
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        if entry.file_type()?.is_file()
            && entry.path().extension().and_then(|s| s.to_str()) == Some("json")
        {
            let raw = std::fs::read_to_string(entry.path())?;
            manifests.push(serde_json::from_str(&raw).map_err(WaxError::JsonError)?);
        }
    }
    manifests.sort_by(|a, b| {
        a.id.to_ascii_lowercase()
            .cmp(&b.id.to_ascii_lowercase())
            .then_with(|| a.ecosystem.cmp(&b.ecosystem))
    });
    Ok(manifests)
}

pub fn find_manifest(raw: &str) -> Result<Option<WindowsPackageManifest>> {
    let spec = crate::package_spec::parse_package_spec(raw);
    if let Some(ecosystem @ (Ecosystem::Scoop | Ecosystem::Winget | Ecosystem::Chocolatey)) =
        spec.force
    {
        return load_manifest(ecosystem, &spec.name);
    }

    let matches: Vec<_> = list_manifests()?
        .into_iter()
        .filter(|m| m.id.eq_ignore_ascii_case(raw))
        .collect();
    if matches.len() > 1 {
        return Err(WaxError::InvalidInput(format!(
            "multiple Windows packages match '{raw}'; use scoop/{raw}, winget/{raw}, or choco/{raw}"
        )));
    }
    Ok(matches.into_iter().next())
}

pub fn validate_bin_links_available(
    ecosystem: Ecosystem,
    id: &str,
    links: &[PathBuf],
) -> Result<()> {
    for manifest in list_manifests()? {
        if manifest.ecosystem == ecosystem && manifest.id.eq_ignore_ascii_case(id) {
            continue;
        }
        for link in links {
            if manifest.bin_links.iter().any(|existing| existing == link) {
                return Err(WaxError::InstallError(format!(
                    "binary link {} is already owned by {}/{}",
                    link.display(),
                    manifest.ecosystem.label(),
                    manifest.id
                )));
            }
        }
    }
    Ok(())
}

pub fn remove_manifest(manifest: &WindowsPackageManifest, dry_run: bool) -> Result<Vec<PathBuf>> {
    let root = wax_windows_root()?;
    let mut removed = Vec::new();
    for path in manifest.bin_links.iter().chain(manifest.files.iter()) {
        if !path_is_under_root(path, &root) {
            return Err(WaxError::InstallError(format!(
                "refusing to remove path outside wax root: {}",
                path.display()
            )));
        }
        if path.exists() || path.is_symlink() {
            removed.push(path.clone());
            if !dry_run {
                std::fs::remove_file(path)?;
            }
        }
    }

    if path_is_under_root(&manifest.staging_dir, &root) && manifest.staging_dir.exists() {
        removed.push(manifest.staging_dir.clone());
        if !dry_run {
            std::fs::remove_dir_all(&manifest.staging_dir)?;
        }
    }

    let path = manifest_path(manifest.ecosystem, &manifest.id)?;
    if path.exists() {
        removed.push(path.clone());
        if !dry_run {
            std::fs::remove_file(path)?;
        }
    }

    Ok(removed)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn collect_files_recurses_and_sorts() {
        let tmp = tempfile::TempDir::new().unwrap();
        let root = tmp.path();
        std::fs::create_dir_all(root.join("bin")).unwrap();
        std::fs::write(root.join("bin/tool.exe"), b"tool").unwrap();
        std::fs::write(root.join("readme.txt"), b"readme").unwrap();

        let files = collect_files(root).unwrap();
        let names: Vec<_> = files
            .iter()
            .map(|path| {
                path.strip_prefix(root)
                    .unwrap()
                    .to_string_lossy()
                    .replace('\\', "/")
            })
            .collect();
        assert_eq!(names, vec!["bin/tool.exe", "readme.txt"]);
    }

    #[test]
    fn manifests_roundtrip_and_list_sorted() {
        let _guard = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::TempDir::new().unwrap();
        std::env::set_var("HOME", tmp.path());
        let one = WindowsPackageManifest::new(
            Ecosystem::Winget,
            "Zoo.Tool",
            "1.0.0",
            "https://example.invalid/zoo.zip",
            wax_windows_root()
                .unwrap()
                .join("winget-apps/Zoo.Tool/1.0.0"),
            Vec::new(),
            Vec::new(),
        );
        let two = WindowsPackageManifest::new(
            Ecosystem::Scoop,
            "alpha",
            "2.0.0",
            "https://example.invalid/alpha.zip",
            wax_windows_root().unwrap().join("scoop-apps/alpha/2.0.0"),
            Vec::new(),
            Vec::new(),
        );
        one.save().unwrap();
        two.save().unwrap();

        let loaded = load_manifest(Ecosystem::Winget, "Zoo.Tool")
            .unwrap()
            .unwrap();
        assert_eq!(loaded.version, "1.0.0");
        let listed = list_manifests().unwrap();
        assert_eq!(listed[0].id, "alpha");
        assert_eq!(listed[1].id, "Zoo.Tool");
    }

    #[test]
    fn remove_manifest_deletes_only_owned_wax_paths() {
        let _guard = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::TempDir::new().unwrap();
        std::env::set_var("HOME", tmp.path());
        let root = wax_windows_root().unwrap();
        let staging = root.join("scoop-apps/tool/1.0.0");
        let bin = root.join("bin/tool.exe");
        std::fs::create_dir_all(&staging).unwrap();
        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
        std::fs::write(staging.join("tool.exe"), b"tool").unwrap();
        std::fs::write(&bin, b"tool").unwrap();
        let manifest = WindowsPackageManifest::new(
            Ecosystem::Scoop,
            "tool",
            "1.0.0",
            "https://example.invalid/tool.zip",
            staging.clone(),
            vec![bin.clone()],
            vec![staging.join("tool.exe")],
        );
        manifest.save().unwrap();

        let removed = remove_manifest(&manifest, false).unwrap();
        assert!(removed.iter().any(|path| path == &bin));
        assert!(!bin.exists());
        assert!(!staging.exists());
        assert!(load_manifest(Ecosystem::Scoop, "tool").unwrap().is_none());
    }

    #[test]
    fn bin_link_collision_reports_existing_owner() {
        let _guard = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::TempDir::new().unwrap();
        std::env::set_var("HOME", tmp.path());
        let bin = wax_bin_dir().unwrap().join("tool.exe");
        let manifest = WindowsPackageManifest::new(
            Ecosystem::Scoop,
            "tool",
            "1.0.0",
            "https://example.invalid/tool.zip",
            wax_windows_root().unwrap().join("scoop-apps/tool/1.0.0"),
            vec![bin.clone()],
            Vec::new(),
        );
        manifest.save().unwrap();

        let err = validate_bin_links_available(Ecosystem::Winget, "Other.Tool", &[bin])
            .unwrap_err()
            .to_string();
        assert!(err.contains("already owned by scoop/tool"));
    }
}