use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uptrakit_shared_types::{PluginCapability, PluginTypeId};
use uuid::Uuid;
use crate::validation::{Validate, ValidationError};
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct CreatePluginConfigRequest {
pub name: String,
pub plugin_type: PluginTypeId,
pub config: serde_json::Value,
#[serde(default = "crate::default_enabled")]
pub enabled: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct UpdatePluginConfigRequest {
pub name: Option<String>,
pub config: Option<serde_json::Value>,
pub enabled: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct PluginConfigResponse {
pub id: Uuid,
pub name: String,
pub plugin_type: PluginTypeId,
pub config: serde_json::Value,
pub enabled: bool,
pub capabilities: Vec<String>,
#[serde(with = "time::serde::rfc3339")]
#[cfg_attr(feature = "openapi", schema(value_type = String, format = DateTime))]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
#[cfg_attr(feature = "openapi", schema(value_type = String, format = DateTime))]
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SelectSource {
RestApi {
path: String,
value_field: String,
label_field: String,
},
Action { action_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SelectOption {
pub value: String,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VisibleWhen {
pub field: String,
pub values: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum FieldType {
#[default]
Text,
Password,
Number,
Select,
MultiSelect,
Textarea,
Toggle,
Hidden,
SshPrivateKey,
Other(String),
}
impl FieldType {
pub fn as_str(&self) -> &str {
match self {
Self::Text => "text",
Self::Password => "password",
Self::Number => "number",
Self::Select => "select",
Self::MultiSelect => "multi_select",
Self::Textarea => "textarea",
Self::Toggle => "toggle",
Self::Hidden => "hidden",
Self::SshPrivateKey => "ssh_private_key",
Self::Other(s) => s.as_str(),
}
}
}
impl From<String> for FieldType {
fn from(s: String) -> Self {
match s.as_str() {
"text" => Self::Text,
"password" => Self::Password,
"number" => Self::Number,
"select" => Self::Select,
"multi_select" => Self::MultiSelect,
"textarea" => Self::Textarea,
"toggle" => Self::Toggle,
"hidden" => Self::Hidden,
"ssh_private_key" => Self::SshPrivateKey,
_ => Self::Other(s),
}
}
}
impl Serialize for FieldType {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for FieldType {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer).map(FieldType::from)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FormField {
pub key: String,
pub label: String,
#[serde(default)]
pub field_type: FieldType,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_value: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<SelectOption>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub select_source: Option<SelectSource>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sensitive: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub list: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible_when: Option<VisibleWhen>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct PluginTypeInfo {
pub plugin_type: PluginTypeId,
pub display_name: String,
pub supports_plugin_configs: bool,
pub capabilities: Vec<PluginCapability>,
pub sample_config: serde_json::Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[cfg_attr(feature = "openapi", schema(value_type = Vec<serde_json::Value>))]
pub config_form_fields: Vec<FormField>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[cfg_attr(feature = "openapi", schema(value_type = Vec<serde_json::Value>))]
pub type_settings_form_fields: Vec<FormField>,
#[serde(default, skip_serializing_if = "is_empty_object")]
pub type_settings_sample: serde_json::Value,
}
fn is_empty_object(v: &serde_json::Value) -> bool {
matches!(v, serde_json::Value::Object(m) if m.is_empty())
}
impl Validate for CreatePluginConfigRequest {
fn validate(&self) -> Result<(), ValidationError> {
if self.name.trim().is_empty() {
return Err(ValidationError {
field: "name",
message: "name must not be empty".to_string(),
});
}
Ok(())
}
}
impl Validate for UpdatePluginConfigRequest {
fn validate(&self) -> Result<(), ValidationError> {
if let Some(name) = &self.name
&& name.trim().is_empty()
{
return Err(ValidationError {
field: "name",
message: "name must not be empty".to_string(),
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::assertions_on_result_states,
reason = "test assertions — is_ok/is_err provides readable failure messages"
)]
use super::*;
use uptrakit_shared_types::{PluginCapability, plugin_ids};
fn sample_uuid() -> Uuid {
Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")
.expect("hard-coded UUID should be valid")
}
#[test]
fn create_request_round_trip() {
let req = CreatePluginConfigRequest {
name: "my-github".to_string(),
plugin_type: plugin_ids::RELEASES_GITHUB.clone(),
config: serde_json::json!({"tag_strip_prefix": "v"}),
enabled: true,
};
let json = serde_json::to_string(&req).expect("serialization should succeed");
let de: CreatePluginConfigRequest =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(de.name, "my-github");
assert_eq!(de.plugin_type, plugin_ids::RELEASES_GITHUB.clone());
assert!(de.enabled);
}
#[test]
fn create_request_enabled_defaults_to_true() {
let json = r#"{"name":"test","plugin_type":"releases.github","config":{}}"#;
let de: CreatePluginConfigRequest =
serde_json::from_str(json).expect("deserialization should succeed");
assert!(de.enabled, "enabled should default to true");
}
#[test]
fn create_request_validate_rejects_empty_name() {
let req = CreatePluginConfigRequest {
name: " ".to_string(),
plugin_type: plugin_ids::RELEASES_GITHUB.clone(),
config: serde_json::json!({}),
enabled: true,
};
let err = req
.validate()
.expect_err("should reject whitespace-only name");
assert_eq!(err.field, "name");
}
#[test]
fn create_request_validate_accepts_valid() {
let req = CreatePluginConfigRequest {
name: "my-plugin".to_string(),
plugin_type: plugin_ids::RELEASES_GITHUB.clone(),
config: serde_json::json!({}),
enabled: true,
};
assert!(req.validate().is_ok());
}
#[test]
fn update_request_round_trip_all_fields() {
let req = UpdatePluginConfigRequest {
name: Some("renamed".to_string()),
config: Some(serde_json::json!({"key": "val"})),
enabled: Some(false),
};
let json = serde_json::to_string(&req).expect("serialization should succeed");
let de: UpdatePluginConfigRequest =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(de.name.as_deref(), Some("renamed"));
assert_eq!(de.enabled, Some(false));
}
#[test]
fn update_request_round_trip_none_fields() {
let req = UpdatePluginConfigRequest {
name: None,
config: None,
enabled: None,
};
let json = serde_json::to_string(&req).expect("serialization should succeed");
let de: UpdatePluginConfigRequest =
serde_json::from_str(&json).expect("deserialization should succeed");
assert!(de.name.is_none());
assert!(de.config.is_none());
assert!(de.enabled.is_none());
}
#[test]
fn update_request_validate_rejects_empty_name() {
let req = UpdatePluginConfigRequest {
name: Some(String::new()),
config: None,
enabled: None,
};
assert_eq!(req.validate().err().map(|e| e.field), Some("name"));
}
#[test]
fn update_request_validate_passes_when_name_omitted() {
let req = UpdatePluginConfigRequest {
name: None,
config: None,
enabled: None,
};
assert!(req.validate().is_ok());
}
#[test]
fn field_type_unknown_round_trip_preserves_value() {
let parsed: FieldType = serde_json::from_str("\"future_picker\"")
.expect("unknown field_type should deserialize");
assert_eq!(parsed, FieldType::Other("future_picker".to_string()));
let serialized =
serde_json::to_string(&parsed).expect("unknown field_type should serialize");
assert_eq!(serialized, "\"future_picker\"");
}
#[test]
fn field_def_default_value_keeps_json_type() {
let field = FormField {
key: "complex".to_string(),
label: "Complex".to_string(),
field_type: FieldType::Text,
required: false,
placeholder: None,
help_text: None,
default_value: Some(serde_json::json!({
"numbers": [1, 2],
"toggle": true
})),
options: vec![],
select_source: None,
sensitive: false,
list: false,
visible_when: None,
};
let json = serde_json::to_value(&field).expect("serialization should succeed");
assert_eq!(
json.get("default_value"),
Some(&serde_json::json!({
"numbers": [1, 2],
"toggle": true
}))
);
}
#[test]
fn plugin_type_info_round_trip() {
let info = PluginTypeInfo {
plugin_type: plugin_ids::RELEASES_DOCKER.clone(),
display_name: "Docker".to_string(),
supports_plugin_configs: true,
capabilities: vec![
PluginCapability::DiscoverLocalSoftware,
PluginCapability::ControllerSideFetchReleases,
],
sample_config: serde_json::json!({"tracking_mode": "semver_tags"}),
config_form_fields: vec![],
type_settings_form_fields: vec![],
type_settings_sample: serde_json::json!({}),
};
let json = serde_json::to_string(&info).expect("serialization should succeed");
let de: PluginTypeInfo =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(de.plugin_type, plugin_ids::RELEASES_DOCKER.clone());
assert_eq!(de.display_name, "Docker");
assert!(de.supports_plugin_configs);
assert_eq!(
de.capabilities,
vec![
PluginCapability::DiscoverLocalSoftware,
PluginCapability::ControllerSideFetchReleases
]
);
assert_eq!(de.sample_config["tracking_mode"], "semver_tags");
}
#[test]
fn plugin_type_info_capabilities_serialize_snake_case() {
let info = PluginTypeInfo {
plugin_type: plugin_ids::RELEASES_GITHUB.clone(),
display_name: "GitHub Releases".to_string(),
supports_plugin_configs: true,
capabilities: vec![PluginCapability::ControllerSideFetchReleases],
sample_config: serde_json::json!({}),
config_form_fields: vec![],
type_settings_form_fields: vec![],
type_settings_sample: serde_json::json!({}),
};
let json = serde_json::to_string(&info).expect("serialization should succeed");
assert!(
json.contains("controller_side_fetch_releases"),
"capability should serialize as snake_case"
);
}
#[test]
fn response_round_trip() {
use time::macros::datetime;
let resp = PluginConfigResponse {
id: sample_uuid(),
name: "docker-hub".to_string(),
plugin_type: plugin_ids::RELEASES_DOCKER.clone(),
config: serde_json::json!({}),
enabled: true,
capabilities: vec!["discover_local_software".to_string()],
created_at: datetime!(2025-01-01 00:00:00 UTC),
updated_at: datetime!(2025-06-01 00:00:00 UTC),
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let de: PluginConfigResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(de.id, sample_uuid());
assert_eq!(de.name, "docker-hub");
assert_eq!(de.plugin_type, plugin_ids::RELEASES_DOCKER.clone());
assert!(de.enabled);
}
}