vfox 2026.7.16

Interface to vfox plugins
Documentation
use mlua::{FromLua, IntoLua, Lua, Value, prelude::LuaError};

use crate::{Plugin, error::Result};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageRequest {
    pub name: String,
    pub version: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageInstalledContext {
    pub packages: Vec<PackageRequest>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageActionContext {
    pub packages: Vec<PackageRequest>,
    pub dry_run: bool,
    pub update: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstalledPackage {
    pub name: String,
    pub state: String,
    pub version: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageInstalledResponse {
    pub packages: Vec<InstalledPackage>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PackageActionResponse;

impl Plugin {
    pub async fn package_installed(
        &self,
        ctx: PackageInstalledContext,
    ) -> Result<PackageInstalledResponse> {
        debug!("[vfox:{}] package_installed", &self.name);
        self.eval_async(chunk! {
            require "hooks/package_installed"
            return PLUGIN:PackageInstalled($ctx)
        })
        .await
    }

    pub async fn package_install(
        &self,
        ctx: PackageActionContext,
    ) -> Result<PackageActionResponse> {
        debug!("[vfox:{}] package_install", &self.name);
        self.eval_async(chunk! {
            require "hooks/package_install"
            return PLUGIN:PackageInstall($ctx)
        })
        .await
    }

    pub async fn package_upgrade(
        &self,
        ctx: PackageActionContext,
    ) -> Result<PackageActionResponse> {
        debug!("[vfox:{}] package_upgrade", &self.name);
        self.eval_async(chunk! {
            require "hooks/package_upgrade"
            return PLUGIN:PackageUpgrade($ctx)
        })
        .await
    }
}

fn packages_into_lua(packages: Vec<PackageRequest>, lua: &Lua) -> mlua::Result<Value> {
    let list = lua.create_table()?;
    for (index, package) in packages.into_iter().enumerate() {
        let item = lua.create_table()?;
        item.set("name", package.name)?;
        item.set("version", package.version)?;
        list.set(index + 1, item)?;
    }
    Ok(Value::Table(list))
}

impl IntoLua for PackageInstalledContext {
    fn into_lua(self, lua: &Lua) -> mlua::Result<Value> {
        let table = lua.create_table()?;
        table.set("packages", packages_into_lua(self.packages, lua)?)?;
        Ok(Value::Table(table))
    }
}

impl IntoLua for PackageActionContext {
    fn into_lua(self, lua: &Lua) -> mlua::Result<Value> {
        let table = lua.create_table()?;
        table.set("packages", packages_into_lua(self.packages, lua)?)?;
        table.set("dry_run", self.dry_run)?;
        table.set("update", self.update)?;
        Ok(Value::Table(table))
    }
}

impl FromLua for PackageInstalledResponse {
    fn from_lua(value: Value, _: &Lua) -> mlua::Result<Self> {
        let Value::Table(table) = value else {
            return Err(LuaError::FromLuaConversionError {
                from: value.type_name(),
                to: "PackageInstalledResponse".to_string(),
                message: Some("Expected table".to_string()),
            });
        };
        let packages = table
            .get::<mlua::Table>("packages")?
            .sequence_values::<mlua::Table>()
            .map(|item| {
                let item = item?;
                Ok(InstalledPackage {
                    name: item.get("name")?,
                    state: item.get("state")?,
                    version: item.get("version")?,
                })
            })
            .collect::<mlua::Result<Vec<_>>>()?;
        Ok(Self { packages })
    }
}

impl FromLua for PackageActionResponse {
    fn from_lua(value: Value, _: &Lua) -> mlua::Result<Self> {
        match value {
            Value::Table(_) | Value::Nil => Ok(Self),
            _ => Err(LuaError::FromLuaConversionError {
                from: value.type_name(),
                to: "PackageActionResponse".to_string(),
                message: Some("Expected table or nil".to_string()),
            }),
        }
    }
}

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

    #[test]
    fn package_context_serializes_versions_as_opaque_strings() {
        let lua = Lua::new();
        let value = PackageActionContext {
            packages: vec![PackageRequest {
                name: "extension".into(),
                version: Some("nightly-2026.07".into()),
            }],
            dry_run: true,
            update: false,
        }
        .into_lua(&lua)
        .unwrap();
        let table = value.as_table().unwrap();
        let packages = table.get::<mlua::Table>("packages").unwrap();
        let package = packages.get::<mlua::Table>(1).unwrap();
        assert_eq!(package.get::<String>("version").unwrap(), "nightly-2026.07");
        assert!(table.get::<bool>("dry_run").unwrap());
    }

    #[test]
    fn installed_response_deserializes() {
        let lua = Lua::new();
        let value: Value = lua
            .load(r#"return { packages = {{ name = "diff", state = "installed", version = "1.3.4" }, { name = "s3", state = "missing" }}}"#)
            .eval()
            .unwrap();
        let response = PackageInstalledResponse::from_lua(value, &lua).unwrap();
        assert_eq!(response.packages[0].version.as_deref(), Some("1.3.4"));
        assert_eq!(response.packages[1].state, "missing");
    }
}