use serde::{Deserialize, Serialize};
use crate::admin::plugin_install::PluginsInstallParams;
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiscoveredPlugin {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub owner: String,
pub sources: Vec<PluginSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub category: PluginCategory,
pub trust_tier: TrustTier,
pub compat: CompatStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_url: Option<String>,
pub install_cmd: String,
pub install_params: PluginsInstallParams,
}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PluginSource {
CratesIo,
GithubTopic {
repo: String,
},
CuratedIndex,
}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PluginCategory {
Channel,
Poller,
Webhook,
Persona,
Tool,
Unknown,
}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TrustTier {
Official,
CommunityIndexed,
Unverified,
}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CompatStatus {
Compatible,
NeedsUpgrade {
required: String,
current: String,
},
Incompatible {
reason: String,
},
Unknown,
}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManifestSummary {
pub plugin_id: String,
pub plugin_version: String,
pub manifest_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdk_requires: Option<String>,
pub category: PluginCategory,
}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourceError {
pub source: String,
pub message: String,
}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginsSearchParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(default)]
pub compat_only: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginsSearchResponse {
pub items: Vec<DiscoveredPlugin>,
pub fetched_at_ms: u64,
#[serde(default)]
pub partial_failures: Vec<SourceError>,
}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginsCompatCheckParams {
pub crate_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginsCompatCheckResponse {
pub compat: CompatStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_summary: Option<ManifestSummary>,
}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginsRefreshIndexParams {}
#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginsRefreshIndexResponse {
pub items_count: usize,
pub sources_ok: Vec<String>,
pub sources_err: Vec<SourceError>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::plugin_install::InstallSource;
fn sample_install_params() -> PluginsInstallParams {
PluginsInstallParams {
crate_name: "nexo-plugin-telegram".into(),
version: Some("0.3.0".into()),
repo: Some("lordmacu/nexo-rs-plugin-telegram".into()),
source: InstallSource::Release,
force: false,
require_signature: false,
skip_signature_verify: false,
}
}
#[test]
fn discovered_plugin_roundtrip_json() {
let plugin = DiscoveredPlugin {
name: "nexo-plugin-telegram".into(),
version: Some("0.3.0".into()),
description: Some("Telegram bot channel".into()),
owner: "lordmacu".into(),
sources: vec![
PluginSource::CratesIo,
PluginSource::GithubTopic {
repo: "lordmacu/nexo-rs-plugin-telegram".into(),
},
PluginSource::CuratedIndex,
],
repo_url: Some("https://github.com/lordmacu/nexo-rs-plugin-telegram".into()),
homepage: None,
tags: vec!["messaging".into(), "telegram".into()],
category: PluginCategory::Channel,
trust_tier: TrustTier::Official,
compat: CompatStatus::Compatible,
manifest_url: Some(
"https://raw.githubusercontent.com/lordmacu/nexo-rs-plugin-telegram/main/nexo-plugin.toml"
.into(),
),
install_cmd: "cargo install nexo-plugin-telegram --version 0.3.0".into(),
install_params: sample_install_params(),
};
let json = serde_json::to_string(&plugin).unwrap();
let parsed: DiscoveredPlugin = serde_json::from_str(&json).unwrap();
assert_eq!(plugin, parsed);
}
#[test]
fn compat_status_needs_upgrade_serializes_with_kind_tag() {
let compat = CompatStatus::NeedsUpgrade {
required: ">=0.2".into(),
current: "0.1.19".into(),
};
let json = serde_json::to_value(&compat).unwrap();
assert_eq!(json["kind"], "needs_upgrade");
assert_eq!(json["required"], ">=0.2");
assert_eq!(json["current"], "0.1.19");
}
#[test]
fn search_params_default_is_unfiltered() {
let p = PluginsSearchParams::default();
assert!(p.query.is_none());
assert!(!p.compat_only);
assert!(p.category.is_none());
assert!(p.source.is_none());
}
#[test]
fn search_response_default_is_empty_catalogue() {
let r = PluginsSearchResponse::default();
assert!(r.items.is_empty());
assert!(r.partial_failures.is_empty());
assert_eq!(r.fetched_at_ms, 0);
}
#[test]
fn search_params_accepts_partial_json() {
let p: PluginsSearchParams = serde_json::from_str("{}").unwrap();
assert!(p.query.is_none());
let p: PluginsSearchParams = serde_json::from_str(r#"{"query":"telegram"}"#).unwrap();
assert_eq!(p.query.as_deref(), Some("telegram"));
}
#[test]
fn compat_check_response_omits_manifest_when_none() {
let r = PluginsCompatCheckResponse {
compat: CompatStatus::Unknown,
manifest_summary: None,
};
let json = serde_json::to_string(&r).unwrap();
assert!(
!json.contains("manifest_summary"),
"manifest_summary must be skipped when None: {json}"
);
}
#[test]
fn plugin_source_github_topic_roundtrip() {
let s = PluginSource::GithubTopic {
repo: "lordmacu/nexo-rs-plugin-foo".into(),
};
let json = serde_json::to_value(&s).unwrap();
assert_eq!(json["kind"], "github_topic");
assert_eq!(json["repo"], "lordmacu/nexo-rs-plugin-foo");
let parsed: PluginSource = serde_json::from_value(json).unwrap();
assert_eq!(s, parsed);
}
}