cotis-cli 0.1.0-alpha

Plugin host for Cotis build, check, and run routines
Documentation
//! Host-side loading and invocation of routine dynamic libraries.
//!
//! Routine plugins export a C ABI documented in the [crate root](crate#plugin-abi). This module
//! resolves symbols, validates API version, and calls `run`, `help`, and optional
//! `finish_installation` hooks.
//!
//! For authoring a plugin, implement the `cotis_plugin_*` exports in your `cdylib` crate.
//! See **cotis-web-builder** and **cotis-android-builder** for complete examples.

use std::ffi::{CStr, CString, c_char};
use std::path::Path;

use libloading::{Library, Symbol};
use serde::{Deserialize, Serialize};

/// Plugin ABI version that [`LoadedPlugin::ensure_compatible`] requires.
pub const COTIS_PLUGIN_API_VERSION: u32 = 1;

type ApiVersionFn = unsafe extern "C" fn() -> u32;
type RunFn = unsafe extern "C" fn(argc: i32, argv: *const *const c_char) -> i32;
type HelpFn = unsafe extern "C" fn() -> *mut c_char;
type DescriptorFn = unsafe extern "C" fn() -> *mut c_char;
type FreeStringFn = unsafe extern "C" fn(ptr: *mut c_char);
type FinishInstallationFn = unsafe extern "C" fn() -> i32;

/// JSON descriptor returned by `cotis_plugin_descriptor_json`.
///
/// Written to `descriptor.json` beside the installed plugin library during [`crate::install::install`].
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PluginDescriptor {
    /// Routine name (cache directory name and `argv[0]` on `run`).
    pub name: String,
    /// Version string reported by the plugin (independent of cache directory version for `path:` installs).
    pub version: String,
    /// When true, `cotis-cli install` calls `cotis_plugin_finish_installation` after files are written.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub finish_installation: bool,
}

/// A loaded routine dynamic library with resolved C symbols.
///
/// Keeps the underlying [`Library`] alive for the lifetime of this value.
#[derive(Debug)]
pub struct LoadedPlugin {
    _lib: Library,
    api_version: Symbol<'static, ApiVersionFn>,
    run: Symbol<'static, RunFn>,
    help: Symbol<'static, HelpFn>,
    descriptor_json: Symbol<'static, DescriptorFn>,
    free_string: Symbol<'static, FreeStringFn>,
    finish_installation: Option<Symbol<'static, FinishInstallationFn>>,
}

impl LoadedPlugin {
    /// Load a routine plugin from a dynamic library path.
    ///
    /// Resolves required symbols: `cotis_plugin_api_version`, `cotis_plugin_run`,
    /// `cotis_plugin_help`, `cotis_plugin_descriptor_json`, `cotis_plugin_free_string`.
    /// Optionally resolves `cotis_plugin_finish_installation`.
    ///
    /// # Safety
    ///
    /// Loading and executing arbitrary dynamic libraries is inherently unsafe. The caller must
    /// ensure `path` points to a trusted plugin built for the host ABI. Symbol function pointers
    /// are transmuted to `'static` lifetimes because this struct owns the [`Library`]; this is a
    /// standard `libloading` pattern.
    ///
    /// # Errors
    ///
    /// Returns an error string if the file cannot be loaded or a required symbol is missing.
    ///
    /// # Examples
    ///
    /// ```ignore
    /// let plugin = unsafe { LoadedPlugin::load(path)? };
    /// plugin.ensure_compatible()?;
    /// ```
    pub unsafe fn load(path: &Path) -> Result<Self, String> {
        let lib = unsafe {
            Library::new(path)
                .map_err(|e| format!("Failed to load plugin {}: {e}", path.display()))?
        };

        unsafe fn sym<T>(lib: &Library, name: &[u8]) -> Result<Symbol<'static, T>, String> {
            let s: Symbol<T> = unsafe {
                lib.get(name).map_err(|e| {
                    format!("Missing symbol {:?}: {e}", String::from_utf8_lossy(name))
                })?
            };
            Ok(unsafe { std::mem::transmute::<Symbol<T>, Symbol<'static, T>>(s) })
        }

        let api_version = unsafe { sym::<ApiVersionFn>(&lib, b"cotis_plugin_api_version\0")? };
        let run = unsafe { sym::<RunFn>(&lib, b"cotis_plugin_run\0")? };
        let help = unsafe { sym::<HelpFn>(&lib, b"cotis_plugin_help\0")? };
        let descriptor_json =
            unsafe { sym::<DescriptorFn>(&lib, b"cotis_plugin_descriptor_json\0")? };
        let free_string = unsafe { sym::<FreeStringFn>(&lib, b"cotis_plugin_free_string\0")? };
        let finish_installation = unsafe {
            lib.get::<FinishInstallationFn>(b"cotis_plugin_finish_installation\0")
                .ok()
                .map(|s| {
                    std::mem::transmute::<
                        Symbol<FinishInstallationFn>,
                        Symbol<'static, FinishInstallationFn>,
                    >(s)
                })
        };

        Ok(Self {
            _lib: lib,
            api_version,
            run,
            help,
            descriptor_json,
            free_string,
            finish_installation,
        })
    }

    /// Verify the plugin's `cotis_plugin_api_version` matches [`COTIS_PLUGIN_API_VERSION`].
    ///
    /// # Errors
    ///
    /// Returns an error if the plugin reports a different API version.
    pub fn ensure_compatible(&self) -> Result<(), String> {
        let v = unsafe { (self.api_version)() };
        if v != COTIS_PLUGIN_API_VERSION {
            return Err(format!(
                "Incompatible plugin API version: plugin={v}, host={}",
                COTIS_PLUGIN_API_VERSION
            ));
        }
        Ok(())
    }

    /// Return help text from `cotis_plugin_help`.
    ///
    /// # Errors
    ///
    /// Returns an error if the plugin returns null, non-UTF-8 data, or allocation fails.
    pub fn help(&self) -> Result<String, String> {
        unsafe { self.get_owned_string(self.help.clone()) }
    }

    /// Return raw descriptor JSON from `cotis_plugin_descriptor_json`.
    ///
    /// # Errors
    ///
    /// Returns an error if the plugin returns null, non-UTF-8 data, or allocation fails.
    pub fn descriptor_json(&self) -> Result<String, String> {
        unsafe { self.get_owned_string(self.descriptor_json.clone()) }
    }

    /// Parse and return the plugin descriptor.
    ///
    /// # Errors
    ///
    /// Returns an error if [`Self::descriptor_json`] fails or JSON does not match [`PluginDescriptor`].
    pub fn descriptor(&self) -> Result<PluginDescriptor, String> {
        let json = self.descriptor_json()?;
        serde_json::from_str(&json).map_err(|e| format!("Failed to parse descriptor JSON: {e}"))
    }

    /// Invoke `cotis_plugin_run` with the given argument vector.
    ///
    /// On `cotis-cli run`, `args[0]` is the routine name; remaining entries are passthrough args.
    ///
    /// # Errors
    ///
    /// Returns an error if any argument contains a NUL byte.
    ///
    /// Returns `Ok(rc)` with the plugin's exit code (`0` = success).
    pub fn run_with_args(&self, args: &[String]) -> Result<i32, String> {
        let cstrs: Vec<CString> = args
            .iter()
            .map(|s| {
                CString::new(s.as_str()).map_err(|_| "Argument contained NUL byte".to_string())
            })
            .collect::<Result<_, _>>()?;
        let ptrs: Vec<*const c_char> = cstrs.iter().map(|s| s.as_ptr()).collect();
        let rc = unsafe { (self.run)(ptrs.len() as i32, ptrs.as_ptr()) };
        Ok(rc)
    }

    /// If the descriptor opts in (`finish_installation: true`), runs `cotis_plugin_finish_installation`.
    ///
    /// The host sets `COTIS_CLI_CACHE_DIR` (and `COTIS_CLI_INSTALL_PROJECT_DIR` for `path:` installs)
    /// before calling this. See [`crate::install`].
    ///
    /// # Errors
    ///
    /// Returns an error if `finish_installation` is true but the symbol is missing, or if the
    /// hook returns a non-zero exit code.
    pub fn finish_installation_if_requested(&self, desc: &PluginDescriptor) -> Result<(), String> {
        if !desc.finish_installation {
            return Ok(());
        }
        let Some(fin) = &self.finish_installation else {
            return Err(
                "Plugin descriptor sets \"finish_installation\": true but the library does not export cotis_plugin_finish_installation"
                    .to_string(),
            );
        };
        let rc = unsafe { (fin)() };
        if rc != 0 {
            return Err(format!(
                "cotis_plugin_finish_installation returned non-zero exit code: {rc}"
            ));
        }
        Ok(())
    }

    unsafe fn get_owned_string(
        &self,
        f: Symbol<'static, unsafe extern "C" fn() -> *mut c_char>,
    ) -> Result<String, String> {
        let ptr = unsafe { (f)() };
        if ptr.is_null() {
            return Err("Plugin returned null string".to_string());
        }
        let s = unsafe {
            CStr::from_ptr(ptr)
                .to_str()
                .map_err(|_| "Plugin returned non-utf8 string".to_string())?
                .to_string()
        };
        unsafe {
            (self.free_string)(ptr);
        }
        Ok(s)
    }
}

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

    #[test]
    fn plugin_descriptor_round_trip_without_finish_installation() {
        let json = r#"{"name":"my_routine","version":"0.1.0"}"#;
        let desc: PluginDescriptor = serde_json::from_str(json).unwrap();
        assert_eq!(desc.name, "my_routine");
        assert_eq!(desc.version, "0.1.0");
        assert!(!desc.finish_installation);
        assert_eq!(serde_json::to_string(&desc).unwrap(), json);
    }

    #[test]
    fn plugin_descriptor_round_trip_with_finish_installation() {
        let json = r#"{"name":"my_routine","version":"0.1.0","finish_installation":true}"#;
        let desc: PluginDescriptor = serde_json::from_str(json).unwrap();
        assert!(desc.finish_installation);
        assert_eq!(serde_json::to_string(&desc).unwrap(), json);
    }
}