Skip to main content

cotis_cli/
lib.rs

1//! # cotis-cli — plugin host for Cotis routines
2//!
3//! `cotis-cli` is a **plugin host** for Cotis build/check/run **routines**: dynamic libraries
4//! (`.dll` / `.so` / `.dylib`) that export a small C ABI. The CLI installs routines into a cache
5//! directory and executes them with passthrough arguments.
6//!
7//! This crate is **not** the Cotis UI library itself; it manages routine plugins that drive
8//! Cotis-related workflows (web builds, Android packaging, and similar tasks).
9//!
10//! ## Audiences
11//!
12//! - **CLI users** install, list, run, and remove routines via the `cotis-cli` binary.
13//! - **Routine authors** ship a `cdylib` exporting `cotis_plugin_*` symbols; see
14//!   [Plugin ABI](#plugin-abi) below and the [`plugin`] module.
15//!
16//! ## CLI commands
17//!
18//! | Command | Purpose |
19//! |---------|---------|
20//! | `install` | Install from `path:`, `crate:`, or `gh:` spec |
21//! | `update` / `reinstall` | Same as `install`, always overwrites |
22//! | `run` | Load plugin and forward args after `--` |
23//! | `build` | Hidden alias of `run` (kept for compatibility) |
24//! | `list` | Installed routine names |
25//! | `remove` | Delete routine cache directory |
26//! | `info` | Print plugin descriptor JSON |
27//! | `help-routine` / `routine-help` | Print plugin help text |
28//! | `completions` | Shell completion script to stdout |
29//!
30//! Global flags: `-v` / `--verbose` (debug logging), `-f` / `--overwrite` / `--fo` (replace existing install).
31//!
32//! ### Install sources
33//!
34//! - `path:<dir>` — build local Cargo project (`cargo build --lib --release`); version stored as `"dev"`; optional alias; `--cdylib-name` override supported.
35//! - `crate:<name>@<version>` — download from crates.io, build; routine name = cdylib stem; alias ignored.
36//! - `gh:<owner>/<repo>@<tag|latest>` — download GitHub release asset matching host triple; `--cdylib-name` not supported.
37//!
38//! ### Interactive mode
39//!
40//! When required arguments are omitted, the CLI prompts interactively. This requires a TTY on
41//! both stdin and stdout. Interactive `install` also forces overwrite. See the binary's
42//! `interactive` module (not part of the library API).
43//!
44//! ### Quick start (CLI user)
45//!
46//! ```text
47//! cotis-cli install path:C:\path\to\cotis-web-builder
48//! cotis-cli list
49//! cotis-cli help-routine cotis_web_builder
50//! cotis-cli run cotis_web_builder -- --output .\dist\web
51//! ```
52//!
53//! `cargo doc` documents this library surface; CLI behavior is summarized here for discoverability.
54//!
55//! ## Install → cache → run lifecycle
56//!
57//! 1. **Install** — build or download a cdylib, validate ABI, write `descriptor.json`, optionally call
58//!    `cotis_plugin_finish_installation`.
59//! 2. **Cache** — files live under [`routines::cache_root`] (see [Cache layout](#cache-layout)).
60//! 3. **Run** — load library, set `COTIS_CLI_CACHE_DIR`, call `cotis_plugin_run` with `argv[0]` = routine name.
61//!
62//! ## Cache layout
63//!
64//! ```text
65//! cache/routines/<routine_name>/<version>/<target_triple>/plugin/<library>
66//! cache/routines/<routine_name>/<version>/<target_triple>/descriptor.json
67//! ```
68//!
69//! `ProjectDirs` qualifier: `"Cotis Layout"`, application name: `"cotis-cli"`.
70//!
71//! Platform library filenames (see [`cdylib_filename`]):
72//! - Windows: `<name>.dll`
73//! - Linux: `lib<name>.so`
74//! - macOS: `lib<name>.dylib`
75//!
76//! When `run <name>` is used without `@version`, the host picks the **lexicographically last**
77//! installed version directory (not semver-aware). Pin with `run <name>@<version>`.
78//!
79//! ## Environment variables
80//!
81//! Set by the host during plugin execution:
82//!
83//! | Variable | When set |
84//! |----------|----------|
85//! | `COTIS_CLI_CACHE_DIR` | `run`; `finish_installation` hook |
86//! | `COTIS_CLI_INSTALL_PROJECT_DIR` | `finish_installation` for `path:` installs only |
87//!
88//! ## Plugin ABI
89//!
90//! A routine dynamic library must export the following C symbols. This is the primary reference
91//! for routine authors (you may not read the README).
92//!
93//! | Symbol | Required | Contract |
94//! |--------|----------|----------|
95//! | `cotis_plugin_api_version()` | Yes | Must return `1` ([`plugin::COTIS_PLUGIN_API_VERSION`]) |
96//! | `cotis_plugin_descriptor_json()` | Yes | UTF-8 JSON → [`plugin::PluginDescriptor`] |
97//! | `cotis_plugin_help()` | Yes | UTF-8 help text |
98//! | `cotis_plugin_run(argc, argv)` | Yes | `0` = success; `argv[0]` is routine name on `run` |
99//! | `cotis_plugin_free_string(ptr)` | Yes | Host frees strings returned by the plugin |
100//! | `cotis_plugin_finish_installation()` | Optional | Required when descriptor sets `"finish_installation": true` |
101//!
102//! ### `PluginDescriptor` JSON
103//!
104//! ```json
105//! {"name":"my_routine","version":"0.1.0","finish_installation":true}
106//! ```
107//!
108//! - `name` — routine identifier (used in cache paths and `argv[0]`).
109//! - `version` — reported version (may differ from cache directory version for `path:` installs, which always use `"dev"`).
110//! - `finish_installation` — when `true`, host calls `cotis_plugin_finish_installation` after install.
111//!
112//! ### String ownership
113//!
114//! Functions returning `*mut c_char` must allocate with something like `CString::new(...).into_raw()`.
115//! The host copies the string and then calls `cotis_plugin_free_string` to release plugin memory.
116//! All strings must be valid UTF-8.
117//!
118//! ### Minimal plugin exports
119//!
120//! ```ignore
121//! use std::ffi::{c_char, CString};
122//!
123//! #[no_mangle]
124//! pub extern "C" fn cotis_plugin_api_version() -> u32 { 1 }
125//!
126//! #[no_mangle]
127//! pub extern "C" fn cotis_plugin_descriptor_json() -> *mut c_char {
128//!     CString::new(r#"{"name":"my_routine","version":"0.1.0"}"#).unwrap().into_raw()
129//! }
130//!
131//! #[no_mangle]
132//! pub extern "C" fn cotis_plugin_help() -> *mut c_char {
133//!     CString::new("My routine help text").unwrap().into_raw()
134//! }
135//!
136//! #[no_mangle]
137//! pub extern "C" fn cotis_plugin_free_string(ptr: *mut c_char) {
138//!     if !ptr.is_null() {
139//!         unsafe { drop(CString::from_raw(ptr)); }
140//!     }
141//! }
142//!
143//! #[no_mangle]
144//! pub extern "C" fn cotis_plugin_run(_argc: i32, _argv: *const *const c_char) -> i32 { 0 }
145//! ```
146//!
147//! ### Reference implementations
148//!
149//! - **cotis-web-builder** — basic ABI without `finish_installation`.
150//! - **cotis-android-builder** — uses `finish_installation: true` and reads `COTIS_CLI_CACHE_DIR` /
151//!   `COTIS_CLI_INSTALL_PROJECT_DIR` during the install hook.
152//!
153//! ## Library modules
154//!
155//! - [`plugin`] — load and invoke routine dynamic libraries.
156//! - [`install`] — parse install specs and install routines into the cache.
157//! - [`routines`] — cache path helpers and installed-version discovery.
158//! - [`CargoTool`] — optional Cargo metadata/build helper for routine build tooling.
159
160pub mod install;
161pub mod plugin;
162pub mod routines;
163
164use std::fs;
165use std::io;
166use std::path::Path;
167use std::process::{Command, Stdio};
168
169use log::{debug, error, warn};
170use serde::Deserialize;
171
172/// Cargo metadata and build helper for routine projects.
173///
174/// Discovers the first `bin` and optional `cdylib` target via `cargo metadata --no-deps`, then
175/// wraps `cargo check`, `cargo build`, and cdylib artifact relocation.
176///
177/// For installing routines into the cotis-cli cache, prefer [`install::install`] which uses
178/// workspace-aware metadata and `cargo build --lib`.
179#[derive(Debug)]
180pub struct CargoTool {
181    /// Name of the first `bin` target from `cargo metadata`.
182    pub binary_name: String,
183    /// Name of the first `cdylib` target, if any.
184    pub cdylib_name: Option<String>,
185}
186
187#[derive(Deserialize)]
188struct Metadata {
189    packages: Vec<Package>,
190}
191
192#[derive(Deserialize)]
193struct Package {
194    #[allow(dead_code)]
195    name: String,
196    targets: Vec<Target>,
197}
198
199#[derive(Deserialize)]
200struct Target {
201    kind: Vec<String>,
202    name: String,
203}
204
205impl CargoTool {
206    /// Discover Cargo targets in the current working directory's workspace.
207    ///
208    /// Runs `cargo metadata --no-deps` and returns the first `bin` target name and optional
209    /// `cdylib` target name.
210    ///
211    /// # Errors
212    ///
213    /// Returns `Err(())` if `cargo metadata` fails, JSON parsing fails, or no `bin` target exists.
214    /// Error details are not preserved; check stderr from the cargo invocation.
215    #[allow(clippy::result_unit_err)]
216    pub fn new() -> Result<CargoTool, ()> {
217        debug!("Using Cargo metadata...");
218        let output = Command::new("cargo")
219            .args(["metadata", "--no-deps", "--format-version", "1"])
220            .stderr(Stdio::inherit())
221            .output()
222            .map_err(|_| ())?;
223
224        if !output.status.success() {
225            return Err(());
226        }
227
228        let metadata: Metadata = serde_json::from_slice(&output.stdout).map_err(|_| ())?;
229
230        let binary_name = metadata
231            .packages
232            .iter()
233            .flat_map(|pkg| &pkg.targets)
234            .find(|t| t.kind.contains(&"bin".to_string()))
235            .map(|t| t.name.clone())
236            .ok_or(())?;
237
238        let cdylib_name = metadata
239            .packages
240            .iter()
241            .flat_map(|pkg| &pkg.targets)
242            .find(|t| t.kind.contains(&"cdylib".to_string()))
243            .map(|t| t.name.clone());
244
245        Ok(CargoTool {
246            binary_name,
247            cdylib_name,
248        })
249    }
250
251    /// Run `cargo check` in the current directory.
252    ///
253    /// # Errors
254    ///
255    /// Returns an I/O error if spawning `cargo` fails.
256    ///
257    /// Returns `Ok(false)` if `cargo check` exits non-zero.
258    pub fn check(&self) -> io::Result<bool> {
259        debug!("Using Cargo check...");
260        let mut command = Command::new("cargo");
261        command.args(["check"]);
262        command.stdout(Stdio::inherit());
263        command.stderr(Stdio::inherit());
264        let status = command.status()?;
265        Ok(status.success())
266    }
267
268    /// Build the project into `out_dir` and move the `bin` artifact to `out_file`.
269    ///
270    /// Runs `cargo build --target-dir <out_dir>` with optional `--release`, then renames the
271    /// built executable (appending `.exe` on Windows).
272    ///
273    /// # Errors
274    ///
275    /// Returns an I/O error if spawning cargo, creating directories, or renaming fails.
276    ///
277    /// Returns `Ok(false)` if `out_dir` does not exist, or if `cargo build` exits non-zero.
278    pub fn build(&self, out_dir: &Path, release: bool, out_file: &Path) -> io::Result<bool> {
279        debug!("Using Cargo build...");
280        if !out_dir.exists() {
281            warn!("Output directory doesn't exist, skipping...");
282            return Ok(false);
283        }
284
285        let mut command = Command::new("cargo");
286        command.args(["build", "--target-dir", out_dir.to_str().unwrap()]);
287        if release {
288            command.arg("--release");
289        }
290        command.stdout(Stdio::inherit());
291        command.stderr(Stdio::inherit());
292
293        let status = command.status()?;
294        if !status.success() {
295            return Ok(false);
296        }
297
298        let profile = if release { "release" } else { "debug" };
299
300        let src_bin = out_dir.join(profile).join(self.binary_name.clone());
301        let src_bin = if cfg!(windows) {
302            src_bin.with_extension("exe")
303        } else {
304            src_bin
305        };
306
307        let dest_bin = if cfg!(windows) {
308            out_file.with_extension("exe")
309        } else {
310            out_file.to_owned()
311        };
312        debug!("Moving output to {}", dest_bin.to_str().unwrap());
313        fs::create_dir_all(dest_bin.parent().unwrap())?;
314        fs::rename(&src_bin, &dest_bin)?;
315
316        Ok(true)
317    }
318
319    /// Build the project's `cdylib` into `out_dir` and move the library to `out_file`.
320    ///
321    /// Requires [`Self::cdylib_name`] to be `Some`. Uses [`cdylib_filename`] for the source path
322    /// under `<out_dir>/<profile>/`.
323    ///
324    /// # Errors
325    ///
326    /// Returns an I/O error if spawning cargo, creating directories, or renaming fails.
327    ///
328    /// Returns `Ok(false)` if `out_dir` does not exist, no cdylib target was found, or
329    /// `cargo build` exits non-zero.
330    pub fn build_cdylib(&self, out_dir: &Path, release: bool, out_file: &Path) -> io::Result<bool> {
331        debug!("Using Cargo build (cdylib)...");
332        if !out_dir.exists() {
333            warn!("Output directory doesn't exist, skipping...");
334            return Ok(false);
335        }
336        let cdylib_name = match self.cdylib_name.as_ref() {
337            Some(v) => v,
338            None => {
339                error!("No cdylib target found in cargo metadata");
340                return Ok(false);
341            }
342        };
343
344        let mut command = Command::new("cargo");
345        command.args(["build", "--target-dir", out_dir.to_str().unwrap()]);
346        if release {
347            command.arg("--release");
348        }
349        command.stdout(Stdio::inherit());
350        command.stderr(Stdio::inherit());
351
352        let status = command.status()?;
353        if !status.success() {
354            return Ok(false);
355        }
356
357        let profile = if release { "release" } else { "debug" };
358        let src = out_dir.join(profile).join(cdylib_filename(cdylib_name));
359
360        fs::create_dir_all(out_file.parent().unwrap())?;
361        fs::rename(&src, out_file)?;
362        Ok(true)
363    }
364}
365
366/// Platform-specific dynamic library filename for a Rust cdylib target stem.
367///
368/// # Examples
369///
370/// On Linux:
371///
372/// ```
373/// # #[cfg(target_os = "linux")]
374/// # {
375/// use cotis_cli::cdylib_filename;
376/// assert_eq!(cdylib_filename("my_routine"), "libmy_routine.so");
377/// # }
378/// ```
379///
380/// On macOS:
381///
382/// ```
383/// # #[cfg(target_os = "macos")]
384/// # {
385/// use cotis_cli::cdylib_filename;
386/// assert_eq!(cdylib_filename("my_routine"), "libmy_routine.dylib");
387/// # }
388/// ```
389///
390/// ```ignore
391/// assert_eq!(cdylib_filename("my_routine"), "my_routine.dll");
392/// ```
393pub fn cdylib_filename(target_name: &str) -> String {
394    if cfg!(windows) {
395        format!("{target_name}.dll")
396    } else if cfg!(target_os = "macos") {
397        format!("lib{target_name}.dylib")
398    } else {
399        format!("lib{target_name}.so")
400    }
401}