Skip to main content

bijux_cli/contracts/
plugin.rs

1use schemars::JsonSchema;
2use semver::Version;
3use serde::{Deserialize, Serialize};
4
5use super::command::Namespace;
6
7/// Stable compatibility range contract for plugins and features.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
9pub struct CompatibilityRange {
10    /// Minimum supported version inclusive.
11    pub min_inclusive: String,
12    /// Optional maximum supported version exclusive.
13    pub max_exclusive: Option<String>,
14}
15
16impl CompatibilityRange {
17    /// Build a validated compatibility range.
18    pub fn new(min_inclusive: &str, max_exclusive: Option<&str>) -> Result<Self, String> {
19        let _ = Version::parse(min_inclusive)
20            .map_err(|error| format!("invalid min_inclusive semver: {error}"))?;
21        if let Some(max) = max_exclusive {
22            let _ = Version::parse(max)
23                .map_err(|error| format!("invalid max_exclusive semver: {error}"))?;
24        }
25        Ok(Self {
26            min_inclusive: min_inclusive.to_string(),
27            max_exclusive: max_exclusive.map(ToString::to_string),
28        })
29    }
30
31    /// Check whether a host version is supported by this range.
32    pub fn supports_host(&self, host_version: &str) -> Result<bool, String> {
33        let host = Version::parse(host_version)
34            .map_err(|error| format!("invalid host semver: {error}"))?;
35        let min = Version::parse(&self.min_inclusive)
36            .map_err(|error| format!("invalid min_inclusive semver: {error}"))?;
37        if host < min {
38            return Ok(false);
39        }
40        if let Some(max) = &self.max_exclusive {
41            let max = Version::parse(max)
42                .map_err(|error| format!("invalid max_exclusive semver: {error}"))?;
43            return Ok(host < max);
44        }
45        Ok(true)
46    }
47}
48
49/// Stable plugin capability declaration.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51pub struct PluginCapability {
52    /// Capability identifier.
53    pub name: String,
54    /// Optional capability version.
55    pub version: Option<String>,
56}
57
58impl PluginCapability {
59    /// Build a validated plugin capability declaration.
60    pub fn new(name: &str, version: Option<&str>) -> Result<Self, String> {
61        if name.trim().is_empty() {
62            return Err("capability name cannot be empty".to_string());
63        }
64        Ok(Self { name: name.to_string(), version: version.map(ToString::to_string) })
65    }
66}
67
68/// Stable plugin kind declaration.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
70#[serde(rename_all = "kebab-case")]
71#[non_exhaustive]
72pub enum PluginKind {
73    /// Future in-process plugin ABI.
74    Native,
75    /// Delegated plugin loaded through host contract bridge.
76    #[default]
77    Delegated,
78    /// Python delegated plugin runtime.
79    Python,
80    /// External executable plugin.
81    ExternalExec,
82}
83
84/// Stable plugin trust-class declaration.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "lowercase")]
87pub enum PluginTrustClass {
88    /// Built and governed by core maintainers.
89    Core,
90    /// Trusted and verified third-party plugin.
91    Verified,
92    /// Community plugin without elevated trust guarantees.
93    Community,
94    /// Unknown trust provenance.
95    Unknown,
96}
97
98/// Stable plugin lifecycle state in registry and diagnostics.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100#[serde(rename_all = "lowercase")]
101#[non_exhaustive]
102pub enum PluginLifecycleState {
103    /// Artifact located during discovery.
104    Discovered,
105    /// Manifest and contract validation passed.
106    Validated,
107    /// Plugin installed in registry.
108    Installed,
109    /// Plugin actively enabled for routing.
110    Enabled,
111    /// Plugin present but inactive.
112    Disabled,
113    /// Plugin failed validation or runtime loading.
114    Broken,
115    /// Plugin failed compatibility checks.
116    Incompatible,
117}
118
119/// Current plugin manifest contract.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
121pub struct PluginManifestV2 {
122    /// Plugin name.
123    pub name: String,
124    /// Plugin version.
125    pub version: String,
126    /// Plugin schema version.
127    pub schema_version: String,
128    /// Manifest contract version.
129    pub manifest_version: String,
130    /// Compatibility range for host CLI.
131    pub compatibility: CompatibilityRange,
132    /// Declared top-level namespace.
133    pub namespace: Namespace,
134    /// Plugin execution kind.
135    #[serde(default)]
136    pub kind: PluginKind,
137    /// Declared trust class.
138    pub trust_class: PluginTrustClass,
139    /// Declared command aliases.
140    #[serde(default)]
141    pub aliases: Vec<String>,
142    /// Plugin entrypoint (binary path or module symbol).
143    pub entrypoint: String,
144    /// Declared capabilities.
145    pub capabilities: Vec<PluginCapability>,
146}
147
148impl PluginManifestV2 {
149    /// Build a validated v2 plugin manifest.
150    #[allow(clippy::too_many_arguments)]
151    pub fn new(
152        name: &str,
153        version: &str,
154        schema_version: &str,
155        manifest_version: &str,
156        compatibility: CompatibilityRange,
157        namespace: Namespace,
158        kind: PluginKind,
159        trust_class: PluginTrustClass,
160        aliases: Vec<String>,
161        entrypoint: &str,
162        capabilities: Vec<PluginCapability>,
163    ) -> Result<Self, String> {
164        if name.trim().is_empty() {
165            return Err("plugin name cannot be empty".to_string());
166        }
167        if version.trim().is_empty() {
168            return Err("plugin version cannot be empty".to_string());
169        }
170        if schema_version.trim().is_empty() {
171            return Err("plugin schema_version cannot be empty".to_string());
172        }
173        if schema_version != "v2" {
174            return Err("plugin schema_version must be v2".to_string());
175        }
176        if manifest_version.trim().is_empty() {
177            return Err("plugin manifest_version cannot be empty".to_string());
178        }
179        if manifest_version != "v2" {
180            return Err("plugin manifest_version must be v2".to_string());
181        }
182        if entrypoint.trim().is_empty() {
183            return Err("plugin entrypoint cannot be empty".to_string());
184        }
185        Ok(Self {
186            name: name.to_string(),
187            version: version.to_string(),
188            schema_version: schema_version.to_string(),
189            manifest_version: manifest_version.to_string(),
190            compatibility,
191            namespace,
192            kind,
193            trust_class,
194            aliases,
195            entrypoint: entrypoint.to_string(),
196            capabilities,
197        })
198    }
199}