use std::collections::BTreeMap;
use std::sync::OnceLock;
use serde::Deserialize;
use crate::error::ManifestError;
use crate::manifest::{
AdminCapabilities, Capabilities, EntrypointSection, HttpServerCapability, MetaSection,
PluginManifest, PluginSection, SubscriptionsSection,
};
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyV1Manifest {
#[serde(default)]
#[allow(dead_code)]
manifest_version: Option<u32>,
plugin: LegacyV1PluginMeta,
#[serde(default)]
capabilities: LegacyV1Capabilities,
transport: LegacyV1Transport,
#[serde(default)]
meta: LegacyV1Meta,
#[serde(default)]
mcp_servers: BTreeMap<String, toml::Value>,
#[serde(default)]
context: LegacyV1ContextConfig,
#[serde(default)]
requires: LegacyV1Requires,
#[serde(default)]
outbound_bindings: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyV1PluginMeta {
id: String,
version: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
min_agent_version: Option<String>,
#[serde(default)]
priority: i32,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyV1Capabilities {
#[serde(default)]
tools: Vec<String>,
#[serde(default)]
hooks: Vec<String>,
#[serde(default)]
channels: Vec<String>,
#[serde(default)]
providers: Vec<String>,
#[serde(default)]
pollers: Vec<String>,
#[serde(default)]
admin: LegacyV1AdminCapabilities,
#[serde(default)]
http_server: Option<LegacyV1HttpServerCapability>,
#[serde(default)]
broker: Option<LegacyV1BrokerCapability>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyV1BrokerCapability {
#[serde(default)]
subscribe: Vec<String>,
#[serde(default)]
publish: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyV1AdminCapabilities {
#[serde(default)]
required: Vec<String>,
#[serde(default)]
optional: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyV1HttpServerCapability {
port: u16,
#[serde(default = "default_legacy_http_bind")]
bind: String,
token_env: String,
#[serde(default = "default_legacy_health_path")]
health_path: String,
#[serde(default)]
extra_env_passthrough: Vec<String>,
}
fn default_legacy_http_bind() -> String {
"127.0.0.1".to_string()
}
fn default_legacy_health_path() -> String {
"/healthz".to_string()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
#[allow(dead_code)] enum LegacyV1Transport {
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
},
Nats {
subject_prefix: String,
},
Http {
url: String,
},
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyV1Meta {
#[serde(default)]
author: Option<String>,
#[serde(default)]
license: Option<String>,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
repository: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyV1ContextConfig {
#[serde(default)]
passthrough: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyV1Requires {
#[serde(default)]
bins: Vec<String>,
#[serde(default)]
env: Vec<String>,
}
pub struct MigrationOutcome {
pub manifest: PluginManifest,
pub dropped_fields: Vec<&'static str>,
}
fn migrate_v1_to_v2(legacy: LegacyV1Manifest) -> Result<MigrationOutcome, ManifestError> {
let mut dropped = Vec::new();
let entrypoint = match legacy.transport {
LegacyV1Transport::Stdio { command, args } => EntrypointSection {
command: Some(command),
args,
env: BTreeMap::new(),
},
LegacyV1Transport::Nats { .. } => {
dropped.push("transport.kind=nats");
EntrypointSection::default()
}
LegacyV1Transport::Http { .. } => {
dropped.push("transport.kind=http");
EntrypointSection::default()
}
};
let admin = AdminCapabilities {
required: legacy.capabilities.admin.required,
optional: legacy.capabilities.admin.optional,
};
let http_server = legacy
.capabilities
.http_server
.map(|h| HttpServerCapability {
port: h.port,
bind: h.bind,
token_env: h.token_env,
health_path: h.health_path,
extra_env_passthrough: h.extra_env_passthrough,
});
if !legacy.capabilities.tools.is_empty() {
dropped.push("capabilities.tools");
}
if !legacy.capabilities.hooks.is_empty() {
dropped.push("capabilities.hooks");
}
if !legacy.capabilities.channels.is_empty() {
dropped.push("capabilities.channels");
}
if !legacy.capabilities.providers.is_empty() {
dropped.push("capabilities.providers");
}
if !legacy.capabilities.pollers.is_empty() {
dropped.push("capabilities.pollers");
}
if legacy
.capabilities
.broker
.as_ref()
.is_some_and(|b| !b.subscribe.is_empty() || !b.publish.is_empty())
{
dropped.push("capabilities.broker");
}
if !legacy.mcp_servers.is_empty() {
dropped.push("mcp_servers");
}
if !legacy.outbound_bindings.is_empty() {
dropped.push("outbound_bindings");
}
if legacy.context.passthrough {
dropped.push("context.passthrough");
}
if !legacy.requires.bins.is_empty() {
dropped.push("requires.bins");
}
if !legacy.requires.env.is_empty() {
dropped.push("requires.env");
}
if legacy.plugin.priority != 0 {
dropped.push("plugin.priority");
}
let plugin = PluginSection {
id: legacy.plugin.id,
version: parse_version(&legacy.plugin.version, "plugin.version")?,
name: legacy.plugin.name.unwrap_or_default(),
description: legacy.plugin.description.unwrap_or_default(),
min_nexo_version: parse_version_req(
legacy
.plugin
.min_agent_version
.as_deref()
.unwrap_or(">=0.0.0"),
"plugin.min_agent_version",
)?,
enabled_by_default: false,
capabilities: Capabilities {
provides: Vec::new(),
admin,
http_server,
skills: Vec::new(),
broker: None,
},
tools: Default::default(),
advisors: Default::default(),
agents: Default::default(),
channels: Default::default(),
skills: Default::default(),
config: Default::default(),
config_schema: None,
credentials_schema: None,
extends: Default::default(),
requires: Default::default(),
capability_gates: Default::default(),
ui: Default::default(),
pairing: Default::default(),
dashboard: None,
metrics: None,
admin: None,
http: None,
admin_ui: None,
poller: None,
public_tunnel: Default::default(),
contracts: Default::default(),
meta: MetaSection {
author: legacy.meta.author,
license: legacy.meta.license,
homepage: legacy.meta.homepage,
repository: legacy.meta.repository,
},
supervisor: Default::default(),
sandbox: Default::default(),
subscriptions: SubscriptionsSection::default(),
http_server: None,
entrypoint,
};
Ok(MigrationOutcome {
manifest: PluginManifest {
manifest_version: 2,
plugin,
},
dropped_fields: dropped,
})
}
fn parse_version(s: &str, field: &'static str) -> Result<semver::Version, ManifestError> {
semver::Version::parse(s).map_err(|_| ManifestError::VersionInvalid {
field,
value: s.to_string(),
})
}
fn parse_version_req(s: &str, field: &'static str) -> Result<semver::VersionReq, ManifestError> {
semver::VersionReq::parse(s).map_err(|_| ManifestError::VersionInvalid {
field,
value: s.to_string(),
})
}
pub fn try_parse_v2_or_v1(raw: &str) -> Result<(PluginManifest, bool), ManifestError> {
let value: toml::Value = toml::from_str(raw)?;
let explicit_version = value
.as_table()
.and_then(|t| t.get("manifest_version"))
.and_then(|v| v.as_integer());
match explicit_version {
Some(2) => {
let parsed: PluginManifest = toml::from_str(raw)?;
Ok((parsed, false))
}
Some(1) => {
let legacy: LegacyV1Manifest = toml::from_str(raw)?;
let outcome = migrate_v1_to_v2(legacy)?;
if !outcome.dropped_fields.is_empty() {
tracing::warn!(
plugin_id = %outcome.manifest.plugin.id,
dropped = ?outcome.dropped_fields,
"manifest_version=1 fields dropped during migration; full preservation is a future addition",
);
}
Ok((outcome.manifest, true))
}
Some(other) => {
use serde::de::Error as _;
Err(ManifestError::Parse(toml::de::Error::custom(format!(
"manifest_version `{other}` not supported (accepted: 1 | 2)"
))))
}
None => {
match toml::from_str::<PluginManifest>(raw) {
Ok(parsed) => Ok((parsed, false)),
Err(_v2_err) => {
let legacy: LegacyV1Manifest = toml::from_str(raw)?;
let outcome = migrate_v1_to_v2(legacy)?;
if !outcome.dropped_fields.is_empty() {
tracing::warn!(
plugin_id = %outcome.manifest.plugin.id,
dropped = ?outcome.dropped_fields,
"manifest_version=1 fields dropped during migration; full preservation is a future addition",
);
}
Ok((outcome.manifest, true))
}
}
}
}
}
static DEPRECATION_WARN_DEDUP: OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
OnceLock::new();
pub fn emit_v1_deprecation_warning(plugin_id: &str, plugin_version: &str) {
let key = format!("{plugin_id}:{plugin_version}");
let dedup = DEPRECATION_WARN_DEDUP
.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
let mut guard = dedup.lock().unwrap_or_else(|p| p.into_inner());
if guard.insert(key) {
tracing::warn!(
plugin_id = %plugin_id,
plugin_version = %plugin_version,
"manifest_version=1 is deprecated; please migrate to v2 before nexo-rs 0.2.0",
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn legacy_minimal_manifest() -> &'static str {
r#"
[plugin]
id = "agent-creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
[transport]
kind = "stdio"
command = "./agent-creator"
"#
}
#[test]
fn try_parse_v1_legacy_returns_was_v1_true() {
let (manifest, was_v1) = try_parse_v2_or_v1(legacy_minimal_manifest()).unwrap();
assert!(was_v1);
assert_eq!(manifest.manifest_version, 2);
assert_eq!(manifest.plugin.id, "agent-creator");
assert_eq!(
manifest.plugin.entrypoint.command.as_deref(),
Some("./agent-creator")
);
}
#[test]
fn try_parse_v2_canonical_returns_was_v1_false() {
let v2 = r#"
manifest_version = 2
[plugin]
id = "agent_creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
min_nexo_version = ">=0.0.0"
"#;
let (manifest, was_v1) = try_parse_v2_or_v1(v2).unwrap();
assert!(!was_v1);
assert_eq!(manifest.manifest_version, 2);
assert_eq!(manifest.plugin.id, "agent_creator");
}
#[test]
fn migrate_legacy_admin_caps_pass_through() {
let raw = r#"
[plugin]
id = "agent-creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
[capabilities.admin]
required = ["agents_crud", "skills_crud"]
optional = ["channels_crud"]
[transport]
kind = "stdio"
command = "./agent-creator"
"#;
let (m, _) = try_parse_v2_or_v1(raw).unwrap();
assert_eq!(
m.plugin.capabilities.admin.required,
vec!["agents_crud".to_string(), "skills_crud".into()]
);
assert_eq!(
m.plugin.capabilities.admin.optional,
vec!["channels_crud".to_string()]
);
}
#[test]
fn migrate_legacy_http_server_pass_through() {
let raw = r#"
[plugin]
id = "agent-creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
[capabilities.http_server]
port = 8765
bind = "127.0.0.1"
token_env = "AGENT_CREATOR_TOKEN"
health_path = "/healthz"
[transport]
kind = "stdio"
command = "./agent-creator"
"#;
let (m, _) = try_parse_v2_or_v1(raw).unwrap();
let http = m.plugin.capabilities.http_server.expect("http_server");
assert_eq!(http.port, 8765);
assert_eq!(http.bind, "127.0.0.1");
assert_eq!(http.token_env, "AGENT_CREATOR_TOKEN");
assert_eq!(http.health_path, "/healthz");
}
#[test]
fn migrate_legacy_http_server_propagates_extra_env_passthrough() {
let raw = r#"
[plugin]
id = "agent-creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
[capabilities.http_server]
port = 8765
bind = "127.0.0.1"
token_env = "AGENT_CREATOR_TOKEN"
health_path = "/healthz"
extra_env_passthrough = ["MARKETING_ADMIN_TOKEN"]
[transport]
kind = "stdio"
command = "./agent-creator"
"#;
let (m, was_v1) = try_parse_v2_or_v1(raw).unwrap();
assert!(was_v1);
let http = m.plugin.capabilities.http_server.expect("http_server");
assert_eq!(http.extra_env_passthrough, vec!["MARKETING_ADMIN_TOKEN"]);
}
#[test]
fn migrate_legacy_http_server_defaults_extra_env_passthrough_empty() {
let raw = r#"
[plugin]
id = "agent-creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
[capabilities.http_server]
port = 8765
bind = "127.0.0.1"
token_env = "AGENT_CREATOR_TOKEN"
[transport]
kind = "stdio"
command = "./agent-creator"
"#;
let (m, _) = try_parse_v2_or_v1(raw).unwrap();
let http = m.plugin.capabilities.http_server.expect("http_server");
assert!(http.extra_env_passthrough.is_empty());
}
#[test]
fn migrate_legacy_meta_block_to_v2() {
let raw = r#"
[plugin]
id = "agent-creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
[transport]
kind = "stdio"
command = "./agent-creator"
[meta]
author = "Cristian"
license = "MIT"
homepage = "https://example.com"
repository = "https://github.com/x/y"
"#;
let (m, _) = try_parse_v2_or_v1(raw).unwrap();
assert_eq!(m.plugin.meta.author.as_deref(), Some("Cristian"));
assert_eq!(m.plugin.meta.license.as_deref(), Some("MIT"));
assert_eq!(
m.plugin.meta.homepage.as_deref(),
Some("https://example.com")
);
assert_eq!(
m.plugin.meta.repository.as_deref(),
Some("https://github.com/x/y")
);
}
#[test]
fn migrate_legacy_min_agent_version_renames_to_min_nexo_version() {
let raw = r#"
[plugin]
id = "agent-creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
min_agent_version = ">=0.1.0"
[transport]
kind = "stdio"
command = "./agent-creator"
"#;
let (m, _) = try_parse_v2_or_v1(raw).unwrap();
assert_eq!(m.plugin.min_nexo_version.to_string(), ">=0.1.0");
}
#[test]
fn migrate_legacy_drops_unsupported_fields_and_returns_list() {
let raw = r#"
[plugin]
id = "agent-creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
[capabilities]
tools = ["foo"]
hooks = ["before_message"]
channels = ["whatsapp"]
providers = ["minimax"]
pollers = ["agent_turn"]
[transport]
kind = "stdio"
command = "./agent-creator"
[mcp_servers.echo]
command = "./echo"
[outbound_bindings]
whatsapp = ["personal"]
[context]
passthrough = true
[requires]
bins = ["jq"]
env = ["MINIMAX_API_KEY"]
"#;
let legacy: LegacyV1Manifest = toml::from_str(raw).unwrap();
let outcome = migrate_v1_to_v2(legacy).unwrap();
let dropped: std::collections::HashSet<&'static str> =
outcome.dropped_fields.iter().copied().collect();
for field in [
"capabilities.tools",
"capabilities.hooks",
"capabilities.channels",
"capabilities.providers",
"capabilities.pollers",
"mcp_servers",
"outbound_bindings",
"context.passthrough",
"requires.bins",
"requires.env",
] {
assert!(
dropped.contains(field),
"expected `{field}` in dropped list; got {dropped:?}"
);
}
}
#[test]
fn migrate_legacy_transport_nats_is_dropped_and_logged() {
let raw = r#"
[plugin]
id = "remote-broker"
version = "0.0.1"
name = "Remote Broker"
description = "x."
[transport]
kind = "nats"
subject_prefix = "ext"
"#;
let legacy: LegacyV1Manifest = toml::from_str(raw).unwrap();
let outcome = migrate_v1_to_v2(legacy).unwrap();
assert!(outcome.dropped_fields.contains(&"transport.kind=nats"));
assert!(outcome.manifest.plugin.entrypoint.command.is_none());
}
#[test]
fn try_parse_explicit_manifest_version_1_uses_legacy_path() {
let raw = r#"
manifest_version = 1
[plugin]
id = "agent-creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
[transport]
kind = "stdio"
command = "./agent-creator"
"#;
let (m, was_v1) = try_parse_v2_or_v1(raw).unwrap();
assert!(was_v1);
assert_eq!(m.manifest_version, 2);
}
#[test]
fn deprecation_warn_dedups_per_id_version() {
emit_v1_deprecation_warning("dedup-test-plugin", "0.0.1");
emit_v1_deprecation_warning("dedup-test-plugin", "0.0.1");
emit_v1_deprecation_warning("dedup-test-plugin", "0.0.2"); let dedup = DEPRECATION_WARN_DEDUP.get().unwrap();
let guard = dedup.lock().unwrap();
assert!(guard.contains("dedup-test-plugin:0.0.1"));
assert!(guard.contains("dedup-test-plugin:0.0.2"));
}
#[test]
fn try_parse_explicit_unsupported_manifest_version_errors() {
let raw = r#"
manifest_version = 99
[plugin]
id = "agent-creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
"#;
let err = try_parse_v2_or_v1(raw).unwrap_err();
let s = err.to_string();
assert!(s.contains("manifest_version `99`"), "got: {s}");
}
#[test]
fn try_parse_unset_version_with_v2_shape_skips_legacy_path() {
let raw = r#"
[plugin]
id = "agent_creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
min_nexo_version = ">=0.0.0"
[plugin.entrypoint]
command = "./agent-creator"
"#;
let (m, was_v1) = try_parse_v2_or_v1(raw).unwrap();
assert!(!was_v1);
assert_eq!(m.plugin.id, "agent_creator");
}
#[test]
fn try_parse_v2_with_unsupported_extra_root_field_errors() {
let raw = r#"
manifest_version = 2
[plugin]
id = "agent-creator"
version = "0.0.1"
name = "Agent Creator"
description = "Operator UI."
min_nexo_version = ">=0.0.0"
[unknown_section]
foo = "bar"
"#;
let result = try_parse_v2_or_v1(raw);
assert!(result.is_err());
}
}