use std::collections::HashMap;
use serde::{Deserialize, Serialize};
pub const CURRENT_PROTOCOL_VERSION: u32 = 1;
pub const MIN_SUPPORTED_PROTOCOL_VERSION: u32 = 1;
fn default_protocol_version() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GuestRequestedPermissions {
#[serde(default)]
pub defaults: serde_json::Value,
#[serde(default)]
pub reasons: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GuestManifest {
#[serde(default = "default_protocol_version")]
pub protocol_version: u32,
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub capabilities: Vec<String>,
#[serde(default)]
pub ui: Vec<serde_json::Value>,
#[serde(default)]
pub commands: Vec<String>,
#[serde(default)]
pub cli: Vec<serde_json::Value>,
#[serde(default)]
pub requested_permissions: Option<GuestRequestedPermissions>,
#[serde(default)]
pub conversions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_app_version: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub server_functions: Vec<ServerFunctionDecl>,
}
impl GuestManifest {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
version: impl Into<String>,
description: impl Into<String>,
capabilities: Vec<String>,
) -> Self {
Self {
protocol_version: CURRENT_PROTOCOL_VERSION,
id: id.into(),
name: name.into(),
version: version.into(),
description: description.into(),
capabilities,
ui: vec![],
commands: vec![],
cli: vec![],
requested_permissions: None,
conversions: vec![],
min_app_version: None,
server_functions: vec![],
}
}
pub fn ui(mut self, ui: Vec<serde_json::Value>) -> Self {
self.ui = ui;
self
}
pub fn commands(mut self, commands: Vec<String>) -> Self {
self.commands = commands;
self
}
pub fn cli(mut self, cli: Vec<serde_json::Value>) -> Self {
self.cli = cli;
self
}
pub fn requested_permissions(mut self, perms: GuestRequestedPermissions) -> Self {
self.requested_permissions = Some(perms);
self
}
pub fn conversions(mut self, conversions: Vec<String>) -> Self {
self.conversions = conversions;
self
}
pub fn min_app_version(mut self, version: impl Into<String>) -> Self {
self.min_app_version = Some(version.into());
self
}
pub fn server_functions(mut self, fns: Vec<ServerFunctionDecl>) -> Self {
self.server_functions = fns;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerFunctionDecl {
pub name: String,
pub method: String,
pub path: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuestEvent {
pub event_type: String,
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandRequest {
pub command: String,
pub params: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandResponse {
pub success: bool,
#[serde(default)]
pub data: Option<serde_json::Value>,
#[serde(default)]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
impl CommandResponse {
pub fn ok(data: serde_json::Value) -> Self {
Self {
success: true,
data: Some(data),
error: None,
error_code: None,
}
}
pub fn ok_empty() -> Self {
Self {
success: true,
data: None,
error: None,
error_code: None,
}
}
pub fn err(message: impl Into<String>) -> Self {
Self {
success: false,
data: None,
error: Some(message.into()),
error_code: None,
}
}
pub fn err_with_code(message: impl Into<String>, code: impl Into<String>) -> Self {
Self {
success: false,
data: None,
error: Some(message.into()),
error_code: Some(code.into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn guest_manifest_roundtrip() {
let manifest = GuestManifest::new(
"diaryx.test",
"Test Plugin",
"0.1.0",
"A test plugin",
vec!["custom_commands".into()],
)
.commands(vec!["do-thing".into()]);
let json = serde_json::to_string(&manifest).unwrap();
let parsed: GuestManifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, "diaryx.test");
assert_eq!(parsed.commands, vec!["do-thing"]);
}
#[test]
fn manifest_defaults_protocol_version() {
let json =
r#"{"id":"test","name":"T","version":"1.0","description":"d","capabilities":[]}"#;
let m: GuestManifest = serde_json::from_str(json).unwrap();
assert_eq!(m.protocol_version, 1);
}
#[test]
fn command_response_helpers() {
let ok = CommandResponse::ok(serde_json::json!({"count": 42}));
assert!(ok.success);
assert_eq!(ok.data.unwrap()["count"], 42);
let err = CommandResponse::err("oops");
assert!(!err.success);
assert_eq!(err.error.as_deref(), Some("oops"));
let err_code = CommandResponse::err_with_code("denied", "permission_denied");
assert_eq!(err_code.error_code.as_deref(), Some("permission_denied"));
}
#[test]
fn command_response_without_error_code() {
let json = r#"{"success":false,"error":"oops"}"#;
let resp: CommandResponse = serde_json::from_str(json).unwrap();
assert!(!resp.success);
assert!(resp.error_code.is_none());
}
#[test]
fn server_function_decl_roundtrip() {
let decl = ServerFunctionDecl {
name: "sync_ws".into(),
method: "WS".into(),
path: "/namespaces/{id}/sync".into(),
description: "CRDT relay WebSocket".into(),
};
let json = serde_json::to_string(&decl).unwrap();
let parsed: ServerFunctionDecl = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "sync_ws");
assert_eq!(parsed.method, "WS");
}
#[test]
fn manifest_with_server_functions() {
let manifest = GuestManifest::new("diaryx.sync", "Sync", "1.0.0", "Sync plugin", vec![])
.server_functions(vec![ServerFunctionDecl {
name: "sync_ws".into(),
method: "WS".into(),
path: "/namespaces/{id}/sync".into(),
description: "CRDT relay".into(),
}]);
let json = serde_json::to_string(&manifest).unwrap();
let parsed: GuestManifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.server_functions.len(), 1);
assert_eq!(parsed.server_functions[0].name, "sync_ws");
}
#[test]
fn manifest_server_functions_defaults_empty() {
let json =
r#"{"id":"test","name":"T","version":"1.0","description":"d","capabilities":[]}"#;
let m: GuestManifest = serde_json::from_str(json).unwrap();
assert!(m.server_functions.is_empty());
}
}