Skip to main content

zsh/extensions/pkg/
manifest.rs

1//! A plugin's optional `znative.toml`, declaring how the plugin is loaded.
2//!
3//! Ported/adapted from strykelang's `pkg/manifest.rs` (`[package]`+`[ffi]`),
4//! trimmed to the two zshrs plugin kinds. When a plugin repo ships no
5//! `znative.toml`, [`PluginKind::detect`] infers the kind from the tree, so
6//! ordinary oh-my-zsh/zinit `*.plugin.zsh` repos install with no metadata.
7//!
8//! Schema:
9//! ```toml
10//! [plugin]
11//! name = "git-fuzzy"
12//! version = "0.1.0"
13//! description = "fzf-driven git UI"
14//!
15//! # Native (Rust cdylib) plugin — dlopened via `zmodload -R`:
16//! [native]
17//! lib = "git_fuzzy"          # produces lib<lib>.{dylib,so}
18//!
19//! # …OR script (zsh) plugin — sourced + added to fpath:
20//! [script]
21//! source = ["git-fuzzy.plugin.zsh"]   # files to `source`, in order
22//! fpath  = ["functions"]              # dirs to prepend to $fpath
23//! ```
24
25use serde::{Deserialize, Serialize};
26use std::path::Path;
27
28use super::{PkgError, PkgResult};
29
30/// Manifest filename, at the root of a plugin's tree.
31pub const MANIFEST_FILE: &str = "znative.toml";
32
33/// Parsed `znative.toml`.
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct PluginManifest {
36    /// `[plugin]` metadata.
37    #[serde(default)]
38    pub plugin: PluginMeta,
39    /// `[native]` — present for Rust cdylib plugins.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub native: Option<NativeSpec>,
42    /// `[script]` — present for zsh script plugins.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub script: Option<ScriptSpec>,
45}
46
47/// `[plugin]` table.
48#[derive(Debug, Clone, Serialize, Deserialize, Default)]
49pub struct PluginMeta {
50    /// `name` — defaults to the source basename when absent.
51    #[serde(default, skip_serializing_if = "String::is_empty")]
52    pub name: String,
53    /// `version` — defaults to `"0.0.0"` when absent.
54    #[serde(default, skip_serializing_if = "String::is_empty")]
55    pub version: String,
56    /// One-line description (shown by `znative list`/`info`).
57    #[serde(default, skip_serializing_if = "String::is_empty")]
58    pub description: String,
59}
60
61/// `[native]` — a Rust cdylib plugin using the `znative` SDK.
62#[derive(Debug, Clone, Serialize, Deserialize, Default)]
63pub struct NativeSpec {
64    /// Library file stem — produces `lib<lib>.{dylib,so}`. When empty the
65    /// installer infers it from the built artifact or the package name.
66    #[serde(default, skip_serializing_if = "String::is_empty")]
67    pub lib: String,
68    /// When true, run `cargo build --release` in the staged tree before
69    /// looking for the cdylib. Defaults to true when a `Cargo.toml` exists.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub build: Option<bool>,
72}
73
74/// `[script]` — a zsh script plugin.
75#[derive(Debug, Clone, Serialize, Deserialize, Default)]
76pub struct ScriptSpec {
77    /// Files to `source`, in order, relative to the plugin root.
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub source: Vec<String>,
80    /// Directories to prepend to `$fpath`, relative to the plugin root.
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub fpath: Vec<String>,
83}
84
85/// Resolved plugin kind — either an explicit `znative.toml` or an inferred layout.
86#[derive(Debug, Clone)]
87pub enum PluginKind {
88    /// Rust cdylib: the `[native]` spec + the built/shipped lib stem.
89    Native(NativeSpec),
90    /// zsh script: files to source + fpath dirs.
91    Script(ScriptSpec),
92}
93
94impl PluginManifest {
95    /// Parse a `znative.toml` string.
96    #[allow(clippy::should_implement_trait)]
97    pub fn from_str(s: &str) -> PkgResult<PluginManifest> {
98        toml::from_str::<PluginManifest>(s)
99            .map_err(|e| PkgError::Manifest(format!("znative.toml: {}", e.message())))
100    }
101
102    /// Load a plugin's `znative.toml` if present at `dir/znative.toml`.
103    pub fn load(dir: &Path) -> PkgResult<Option<PluginManifest>> {
104        let path = dir.join(MANIFEST_FILE);
105        if !path.is_file() {
106            return Ok(None);
107        }
108        let s = std::fs::read_to_string(&path)
109            .map_err(|e| PkgError::Io(format!("read {}: {}", path.display(), e)))?;
110        Ok(Some(PluginManifest::from_str(&s)?))
111    }
112}
113
114impl PluginKind {
115    /// Determine the plugin kind for a staged tree. Prefers an explicit
116    /// `znative.toml` (`[native]` beats `[script]` when both present), then falls
117    /// back to layout detection:
118    ///
119    /// 1. A prebuilt `lib*.{dylib,so}` anywhere, or a `Cargo.toml` whose
120    ///    `[lib] crate-type` mentions `cdylib` → [`PluginKind::Native`].
121    /// 2. Any `*.plugin.zsh` (source it; add its dir to fpath), or a
122    ///    `functions/` dir, or `*.zsh` files → [`PluginKind::Script`].
123    ///
124    /// Returns [`PkgError::Unknown`] when nothing matches.
125    pub fn detect(dir: &Path, manifest: Option<&PluginManifest>) -> PkgResult<PluginKind> {
126        if let Some(m) = manifest {
127            if let Some(n) = &m.native {
128                return Ok(PluginKind::Native(n.clone()));
129            }
130            if let Some(s) = &m.script {
131                return Ok(PluginKind::Script(s.clone()));
132            }
133        }
134        // Layout detection — native first (a repo can carry both a build tree
135        // and helper .zsh, but if it declares a cdylib it's a native plugin).
136        if has_cdylib(dir) || cargo_is_cdylib(dir) {
137            return Ok(PluginKind::Native(NativeSpec::default()));
138        }
139        let mut source: Vec<String> = Vec::new();
140        let mut fpath: Vec<String> = Vec::new();
141        for entry in std::fs::read_dir(dir)? {
142            let name = entry?.file_name().to_string_lossy().into_owned();
143            if name.ends_with(".plugin.zsh") {
144                source.push(name);
145            }
146        }
147        if dir.join("functions").is_dir() {
148            fpath.push("functions".into());
149        }
150        // Fallback: a lone `<basename>.zsh` or any `.zsh`.
151        if source.is_empty() {
152            if let Some(base) = dir.file_name().and_then(|s| s.to_str()) {
153                let cand = format!("{}.zsh", base);
154                if dir.join(&cand).is_file() {
155                    source.push(cand);
156                }
157            }
158        }
159        if source.is_empty() && fpath.is_empty() {
160            for entry in std::fs::read_dir(dir)? {
161                let name = entry?.file_name().to_string_lossy().into_owned();
162                if name.ends_with(".zsh") {
163                    source.push(name);
164                }
165            }
166        }
167        if source.is_empty() && fpath.is_empty() {
168            return Err(PkgError::Unknown(
169                "could not determine plugin kind: no znative.toml, no *.plugin.zsh, \
170                 no functions/, and no Rust cdylib/Cargo.toml"
171                    .into(),
172            ));
173        }
174        source.sort();
175        Ok(PluginKind::Script(ScriptSpec { source, fpath }))
176    }
177}
178
179/// True if a `lib*.{dylib,so}` exists at the tree root (a prebuilt cdylib).
180fn has_cdylib(dir: &Path) -> bool {
181    let Ok(rd) = std::fs::read_dir(dir) else {
182        return false;
183    };
184    for entry in rd.flatten() {
185        let n = entry.file_name();
186        let n = n.to_string_lossy();
187        if n.starts_with("lib") && (n.ends_with(".dylib") || n.ends_with(".so")) {
188            return true;
189        }
190    }
191    false
192}
193
194/// True if `Cargo.toml` declares a `cdylib` crate-type (so `cargo build`
195/// produces a dlopen-able library).
196fn cargo_is_cdylib(dir: &Path) -> bool {
197    let cargo = dir.join("Cargo.toml");
198    let Ok(s) = std::fs::read_to_string(&cargo) else {
199        return false;
200    };
201    s.contains("cdylib")
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn parses_native_manifest() {
210        let m = PluginManifest::from_str(
211            "[plugin]\nname='x'\nversion='0.1.0'\n[native]\nlib='foo'\n",
212        )
213        .unwrap();
214        assert_eq!(m.plugin.name, "x");
215        assert_eq!(m.native.unwrap().lib, "foo");
216    }
217
218    #[test]
219    fn parses_script_manifest() {
220        let m = PluginManifest::from_str(
221            "[plugin]\nname='y'\n[script]\nsource=['y.plugin.zsh']\nfpath=['fns']\n",
222        )
223        .unwrap();
224        let s = m.script.unwrap();
225        assert_eq!(s.source, vec!["y.plugin.zsh"]);
226        assert_eq!(s.fpath, vec!["fns"]);
227    }
228}