cotis-cli 0.1.0-alpha

Plugin host for Cotis build, check, and run routines
Documentation
//! # cotis-cli — plugin host for Cotis routines
//!
//! `cotis-cli` is a **plugin host** for Cotis build/check/run **routines**: dynamic libraries
//! (`.dll` / `.so` / `.dylib`) that export a small C ABI. The CLI installs routines into a cache
//! directory and executes them with passthrough arguments.
//!
//! This crate is **not** the Cotis UI library itself; it manages routine plugins that drive
//! Cotis-related workflows (web builds, Android packaging, and similar tasks).
//!
//! ## Audiences
//!
//! - **CLI users** install, list, run, and remove routines via the `cotis-cli` binary.
//! - **Routine authors** ship a `cdylib` exporting `cotis_plugin_*` symbols; see
//!   [Plugin ABI](#plugin-abi) below and the [`plugin`] module.
//!
//! ## CLI commands
//!
//! | Command | Purpose |
//! |---------|---------|
//! | `install` | Install from `path:`, `crate:`, or `gh:` spec |
//! | `update` / `reinstall` | Same as `install`, always overwrites |
//! | `run` | Load plugin and forward args after `--` |
//! | `build` | Hidden alias of `run` (kept for compatibility) |
//! | `list` | Installed routine names |
//! | `remove` | Delete routine cache directory |
//! | `info` | Print plugin descriptor JSON |
//! | `help-routine` / `routine-help` | Print plugin help text |
//! | `completions` | Shell completion script to stdout |
//!
//! Global flags: `-v` / `--verbose` (debug logging), `-f` / `--overwrite` / `--fo` (replace existing install).
//!
//! ### Install sources
//!
//! - `path:<dir>` — build local Cargo project (`cargo build --lib --release`); version stored as `"dev"`; optional alias; `--cdylib-name` override supported.
//! - `crate:<name>@<version>` — download from crates.io, build; routine name = cdylib stem; alias ignored.
//! - `gh:<owner>/<repo>@<tag|latest>` — download GitHub release asset matching host triple; `--cdylib-name` not supported.
//!
//! ### Interactive mode
//!
//! When required arguments are omitted, the CLI prompts interactively. This requires a TTY on
//! both stdin and stdout. Interactive `install` also forces overwrite. See the binary's
//! `interactive` module (not part of the library API).
//!
//! ### Quick start (CLI user)
//!
//! ```text
//! cotis-cli install path:C:\path\to\cotis-web-builder
//! cotis-cli list
//! cotis-cli help-routine cotis_web_builder
//! cotis-cli run cotis_web_builder -- --output .\dist\web
//! ```
//!
//! `cargo doc` documents this library surface; CLI behavior is summarized here for discoverability.
//!
//! ## Install → cache → run lifecycle
//!
//! 1. **Install** — build or download a cdylib, validate ABI, write `descriptor.json`, optionally call
//!    `cotis_plugin_finish_installation`.
//! 2. **Cache** — files live under [`routines::cache_root`] (see [Cache layout](#cache-layout)).
//! 3. **Run** — load library, set `COTIS_CLI_CACHE_DIR`, call `cotis_plugin_run` with `argv[0]` = routine name.
//!
//! ## Cache layout
//!
//! ```text
//! cache/routines/<routine_name>/<version>/<target_triple>/plugin/<library>
//! cache/routines/<routine_name>/<version>/<target_triple>/descriptor.json
//! ```
//!
//! `ProjectDirs` qualifier: `"Cotis Layout"`, application name: `"cotis-cli"`.
//!
//! Platform library filenames (see [`cdylib_filename`]):
//! - Windows: `<name>.dll`
//! - Linux: `lib<name>.so`
//! - macOS: `lib<name>.dylib`
//!
//! When `run <name>` is used without `@version`, the host picks the **lexicographically last**
//! installed version directory (not semver-aware). Pin with `run <name>@<version>`.
//!
//! ## Environment variables
//!
//! Set by the host during plugin execution:
//!
//! | Variable | When set |
//! |----------|----------|
//! | `COTIS_CLI_CACHE_DIR` | `run`; `finish_installation` hook |
//! | `COTIS_CLI_INSTALL_PROJECT_DIR` | `finish_installation` for `path:` installs only |
//!
//! ## Plugin ABI
//!
//! A routine dynamic library must export the following C symbols. This is the primary reference
//! for routine authors (you may not read the README).
//!
//! | Symbol | Required | Contract |
//! |--------|----------|----------|
//! | `cotis_plugin_api_version()` | Yes | Must return `1` ([`plugin::COTIS_PLUGIN_API_VERSION`]) |
//! | `cotis_plugin_descriptor_json()` | Yes | UTF-8 JSON → [`plugin::PluginDescriptor`] |
//! | `cotis_plugin_help()` | Yes | UTF-8 help text |
//! | `cotis_plugin_run(argc, argv)` | Yes | `0` = success; `argv[0]` is routine name on `run` |
//! | `cotis_plugin_free_string(ptr)` | Yes | Host frees strings returned by the plugin |
//! | `cotis_plugin_finish_installation()` | Optional | Required when descriptor sets `"finish_installation": true` |
//!
//! ### `PluginDescriptor` JSON
//!
//! ```json
//! {"name":"my_routine","version":"0.1.0","finish_installation":true}
//! ```
//!
//! - `name` — routine identifier (used in cache paths and `argv[0]`).
//! - `version` — reported version (may differ from cache directory version for `path:` installs, which always use `"dev"`).
//! - `finish_installation` — when `true`, host calls `cotis_plugin_finish_installation` after install.
//!
//! ### String ownership
//!
//! Functions returning `*mut c_char` must allocate with something like `CString::new(...).into_raw()`.
//! The host copies the string and then calls `cotis_plugin_free_string` to release plugin memory.
//! All strings must be valid UTF-8.
//!
//! ### Minimal plugin exports
//!
//! ```ignore
//! use std::ffi::{c_char, CString};
//!
//! #[no_mangle]
//! pub extern "C" fn cotis_plugin_api_version() -> u32 { 1 }
//!
//! #[no_mangle]
//! pub extern "C" fn cotis_plugin_descriptor_json() -> *mut c_char {
//!     CString::new(r#"{"name":"my_routine","version":"0.1.0"}"#).unwrap().into_raw()
//! }
//!
//! #[no_mangle]
//! pub extern "C" fn cotis_plugin_help() -> *mut c_char {
//!     CString::new("My routine help text").unwrap().into_raw()
//! }
//!
//! #[no_mangle]
//! pub extern "C" fn cotis_plugin_free_string(ptr: *mut c_char) {
//!     if !ptr.is_null() {
//!         unsafe { drop(CString::from_raw(ptr)); }
//!     }
//! }
//!
//! #[no_mangle]
//! pub extern "C" fn cotis_plugin_run(_argc: i32, _argv: *const *const c_char) -> i32 { 0 }
//! ```
//!
//! ### Reference implementations
//!
//! - **cotis-web-builder** — basic ABI without `finish_installation`.
//! - **cotis-android-builder** — uses `finish_installation: true` and reads `COTIS_CLI_CACHE_DIR` /
//!   `COTIS_CLI_INSTALL_PROJECT_DIR` during the install hook.
//!
//! ## Library modules
//!
//! - [`plugin`] — load and invoke routine dynamic libraries.
//! - [`install`] — parse install specs and install routines into the cache.
//! - [`routines`] — cache path helpers and installed-version discovery.
//! - [`CargoTool`] — optional Cargo metadata/build helper for routine build tooling.

pub mod install;
pub mod plugin;
pub mod routines;

use std::fs;
use std::io;
use std::path::Path;
use std::process::{Command, Stdio};

use log::{debug, error, warn};
use serde::Deserialize;

/// Cargo metadata and build helper for routine projects.
///
/// Discovers the first `bin` and optional `cdylib` target via `cargo metadata --no-deps`, then
/// wraps `cargo check`, `cargo build`, and cdylib artifact relocation.
///
/// For installing routines into the cotis-cli cache, prefer [`install::install`] which uses
/// workspace-aware metadata and `cargo build --lib`.
#[derive(Debug)]
pub struct CargoTool {
    /// Name of the first `bin` target from `cargo metadata`.
    pub binary_name: String,
    /// Name of the first `cdylib` target, if any.
    pub cdylib_name: Option<String>,
}

#[derive(Deserialize)]
struct Metadata {
    packages: Vec<Package>,
}

#[derive(Deserialize)]
struct Package {
    #[allow(dead_code)]
    name: String,
    targets: Vec<Target>,
}

#[derive(Deserialize)]
struct Target {
    kind: Vec<String>,
    name: String,
}

impl CargoTool {
    /// Discover Cargo targets in the current working directory's workspace.
    ///
    /// Runs `cargo metadata --no-deps` and returns the first `bin` target name and optional
    /// `cdylib` target name.
    ///
    /// # Errors
    ///
    /// Returns `Err(())` if `cargo metadata` fails, JSON parsing fails, or no `bin` target exists.
    /// Error details are not preserved; check stderr from the cargo invocation.
    #[allow(clippy::result_unit_err)]
    pub fn new() -> Result<CargoTool, ()> {
        debug!("Using Cargo metadata...");
        let output = Command::new("cargo")
            .args(["metadata", "--no-deps", "--format-version", "1"])
            .stderr(Stdio::inherit())
            .output()
            .map_err(|_| ())?;

        if !output.status.success() {
            return Err(());
        }

        let metadata: Metadata = serde_json::from_slice(&output.stdout).map_err(|_| ())?;

        let binary_name = metadata
            .packages
            .iter()
            .flat_map(|pkg| &pkg.targets)
            .find(|t| t.kind.contains(&"bin".to_string()))
            .map(|t| t.name.clone())
            .ok_or(())?;

        let cdylib_name = metadata
            .packages
            .iter()
            .flat_map(|pkg| &pkg.targets)
            .find(|t| t.kind.contains(&"cdylib".to_string()))
            .map(|t| t.name.clone());

        Ok(CargoTool {
            binary_name,
            cdylib_name,
        })
    }

    /// Run `cargo check` in the current directory.
    ///
    /// # Errors
    ///
    /// Returns an I/O error if spawning `cargo` fails.
    ///
    /// Returns `Ok(false)` if `cargo check` exits non-zero.
    pub fn check(&self) -> io::Result<bool> {
        debug!("Using Cargo check...");
        let mut command = Command::new("cargo");
        command.args(["check"]);
        command.stdout(Stdio::inherit());
        command.stderr(Stdio::inherit());
        let status = command.status()?;
        Ok(status.success())
    }

    /// Build the project into `out_dir` and move the `bin` artifact to `out_file`.
    ///
    /// Runs `cargo build --target-dir <out_dir>` with optional `--release`, then renames the
    /// built executable (appending `.exe` on Windows).
    ///
    /// # Errors
    ///
    /// Returns an I/O error if spawning cargo, creating directories, or renaming fails.
    ///
    /// Returns `Ok(false)` if `out_dir` does not exist, or if `cargo build` exits non-zero.
    pub fn build(&self, out_dir: &Path, release: bool, out_file: &Path) -> io::Result<bool> {
        debug!("Using Cargo build...");
        if !out_dir.exists() {
            warn!("Output directory doesn't exist, skipping...");
            return Ok(false);
        }

        let mut command = Command::new("cargo");
        command.args(["build", "--target-dir", out_dir.to_str().unwrap()]);
        if release {
            command.arg("--release");
        }
        command.stdout(Stdio::inherit());
        command.stderr(Stdio::inherit());

        let status = command.status()?;
        if !status.success() {
            return Ok(false);
        }

        let profile = if release { "release" } else { "debug" };

        let src_bin = out_dir.join(profile).join(self.binary_name.clone());
        let src_bin = if cfg!(windows) {
            src_bin.with_extension("exe")
        } else {
            src_bin
        };

        let dest_bin = if cfg!(windows) {
            out_file.with_extension("exe")
        } else {
            out_file.to_owned()
        };
        debug!("Moving output to {}", dest_bin.to_str().unwrap());
        fs::create_dir_all(dest_bin.parent().unwrap())?;
        fs::rename(&src_bin, &dest_bin)?;

        Ok(true)
    }

    /// Build the project's `cdylib` into `out_dir` and move the library to `out_file`.
    ///
    /// Requires [`Self::cdylib_name`] to be `Some`. Uses [`cdylib_filename`] for the source path
    /// under `<out_dir>/<profile>/`.
    ///
    /// # Errors
    ///
    /// Returns an I/O error if spawning cargo, creating directories, or renaming fails.
    ///
    /// Returns `Ok(false)` if `out_dir` does not exist, no cdylib target was found, or
    /// `cargo build` exits non-zero.
    pub fn build_cdylib(&self, out_dir: &Path, release: bool, out_file: &Path) -> io::Result<bool> {
        debug!("Using Cargo build (cdylib)...");
        if !out_dir.exists() {
            warn!("Output directory doesn't exist, skipping...");
            return Ok(false);
        }
        let cdylib_name = match self.cdylib_name.as_ref() {
            Some(v) => v,
            None => {
                error!("No cdylib target found in cargo metadata");
                return Ok(false);
            }
        };

        let mut command = Command::new("cargo");
        command.args(["build", "--target-dir", out_dir.to_str().unwrap()]);
        if release {
            command.arg("--release");
        }
        command.stdout(Stdio::inherit());
        command.stderr(Stdio::inherit());

        let status = command.status()?;
        if !status.success() {
            return Ok(false);
        }

        let profile = if release { "release" } else { "debug" };
        let src = out_dir.join(profile).join(cdylib_filename(cdylib_name));

        fs::create_dir_all(out_file.parent().unwrap())?;
        fs::rename(&src, out_file)?;
        Ok(true)
    }
}

/// Platform-specific dynamic library filename for a Rust cdylib target stem.
///
/// # Examples
///
/// On Linux:
///
/// ```
/// # #[cfg(target_os = "linux")]
/// # {
/// use cotis_cli::cdylib_filename;
/// assert_eq!(cdylib_filename("my_routine"), "libmy_routine.so");
/// # }
/// ```
///
/// On macOS:
///
/// ```
/// # #[cfg(target_os = "macos")]
/// # {
/// use cotis_cli::cdylib_filename;
/// assert_eq!(cdylib_filename("my_routine"), "libmy_routine.dylib");
/// # }
/// ```
///
/// ```ignore
/// assert_eq!(cdylib_filename("my_routine"), "my_routine.dll");
/// ```
pub fn cdylib_filename(target_name: &str) -> String {
    if cfg!(windows) {
        format!("{target_name}.dll")
    } else if cfg!(target_os = "macos") {
        format!("lib{target_name}.dylib")
    } else {
        format!("lib{target_name}.so")
    }
}