use serde::{Deserialize, Serialize};
pub const SOCIAL_PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum SocialPluginRequest {
CreateDraft(CreateSocialDraftParams),
CreateScheduled(CreateScheduledParams),
DraftStatus(SocialDraftStatusParams),
Health(SocialHealthParams),
Capabilities(SocialCapabilitiesParams),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SocialPluginResponse {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scheduled_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scheduled_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<SocialPostState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capabilities: Option<Vec<String>>,
}
impl SocialPluginResponse {
pub fn ok() -> Self {
Self {
ok: true,
error: None,
draft_id: None,
scheduled_id: None,
scheduled_at: None,
state: None,
handle: None,
provider: None,
capabilities: None,
}
}
pub fn error(msg: impl Into<String>) -> Self {
Self {
ok: false,
error: Some(msg.into()),
draft_id: None,
scheduled_id: None,
scheduled_at: None,
state: None,
handle: None,
provider: None,
capabilities: None,
}
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct CreateSocialDraftParams {
pub post: SocialPostContent,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct SocialPostContent {
pub body: String,
#[serde(default)]
pub media_urls: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reply_to_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct CreateScheduledParams {
pub post: SocialPostContent,
pub scheduled_at: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct SocialDraftStatusParams {
pub draft_id: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(rename_all = "snake_case")]
pub enum SocialPostState {
Draft,
Published,
Deleted,
Unknown,
}
impl std::fmt::Display for SocialPostState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SocialPostState::Draft => write!(f, "draft"),
SocialPostState::Published => write!(f, "published"),
SocialPostState::Deleted => write!(f, "deleted"),
SocialPostState::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
pub struct SocialHealthParams {}
#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
pub struct SocialCapabilitiesParams {}
#[derive(Debug, thiserror::Error)]
pub enum SocialPluginError {
#[error("social plugin not found: {name}. Install with: ta adapter setup social/{name}")]
PluginNotFound { name: String },
#[error("social plugin '{name}' op '{op}' failed: {reason}")]
OpFailed {
name: String,
op: String,
reason: String,
},
#[error("social plugin '{name}' produced invalid response for op '{op}': {reason}")]
InvalidResponse {
name: String,
op: String,
reason: String,
},
#[error("failed to spawn social plugin '{command}': {reason}. Ensure the plugin is on PATH.")]
SpawnFailed { command: String, reason: String },
#[error("social plugin '{name}' timed out after {timeout_secs}s for op '{op}'. Increase timeout in plugin.toml.")]
Timeout {
name: String,
op: String,
timeout_secs: u64,
},
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_draft_request_roundtrip() {
let req = SocialPluginRequest::CreateDraft(CreateSocialDraftParams {
post: SocialPostContent {
body: "Excited to announce the cinepipe launch! 🎬".to_string(),
media_urls: vec![],
reply_to_id: None,
},
});
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"op\":\"create_draft\""));
let parsed: SocialPluginRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, req);
}
#[test]
fn create_scheduled_request_roundtrip() {
let req = SocialPluginRequest::CreateScheduled(CreateScheduledParams {
post: SocialPostContent {
body: "Week 1 of our public alpha is live!".to_string(),
media_urls: vec!["https://example.com/screenshot.png".to_string()],
reply_to_id: None,
},
scheduled_at: "2026-04-07T14:00:00Z".to_string(),
});
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"op\":\"create_scheduled\""));
assert!(json.contains("2026-04-07T14:00:00Z"));
let parsed: SocialPluginRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, req);
}
#[test]
fn no_publish_op_variant() {
let req = SocialPluginRequest::Health(SocialHealthParams {});
let json = serde_json::to_string(&req).unwrap();
assert!(
!json.contains("\"publish\""),
"Publish op must not exist in the social protocol"
);
}
#[test]
fn draft_status_request_roundtrip() {
let req = SocialPluginRequest::DraftStatus(SocialDraftStatusParams {
draft_id: "linkedin-draft-xyz".to_string(),
});
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"op\":\"draft_status\""));
let parsed: SocialPluginRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, req);
}
#[test]
fn health_request_roundtrip() {
let req = SocialPluginRequest::Health(SocialHealthParams {});
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"op\":\"health\""));
let parsed: SocialPluginRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, req);
}
#[test]
fn response_ok_roundtrip() {
let resp = SocialPluginResponse::ok();
let json = serde_json::to_string(&resp).unwrap();
let parsed: SocialPluginResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.ok);
assert!(parsed.error.is_none());
}
#[test]
fn response_error_roundtrip() {
let resp = SocialPluginResponse::error("credentials not found");
let json = serde_json::to_string(&resp).unwrap();
let parsed: SocialPluginResponse = serde_json::from_str(&json).unwrap();
assert!(!parsed.ok);
assert_eq!(parsed.error.as_deref(), Some("credentials not found"));
}
#[test]
fn response_with_draft_id() {
let mut resp = SocialPluginResponse::ok();
resp.draft_id = Some("linkedin-draft-abc123".to_string());
let json = serde_json::to_string(&resp).unwrap();
let parsed: SocialPluginResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.draft_id.as_deref(), Some("linkedin-draft-abc123"));
}
#[test]
fn response_with_scheduled_id() {
let mut resp = SocialPluginResponse::ok();
resp.scheduled_id = Some("buffer-post-xyz".to_string());
resp.scheduled_at = Some("2026-04-07T14:00:00Z".to_string());
let json = serde_json::to_string(&resp).unwrap();
let parsed: SocialPluginResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.scheduled_id.as_deref(), Some("buffer-post-xyz"));
assert_eq!(parsed.scheduled_at.as_deref(), Some("2026-04-07T14:00:00Z"));
}
#[test]
fn post_state_display() {
assert_eq!(SocialPostState::Draft.to_string(), "draft");
assert_eq!(SocialPostState::Published.to_string(), "published");
assert_eq!(SocialPostState::Deleted.to_string(), "deleted");
assert_eq!(SocialPostState::Unknown.to_string(), "unknown");
}
#[test]
fn post_state_roundtrip() {
for state in [
SocialPostState::Draft,
SocialPostState::Published,
SocialPostState::Deleted,
SocialPostState::Unknown,
] {
let json = serde_json::to_string(&state).unwrap();
let parsed: SocialPostState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, state);
}
}
#[test]
fn social_protocol_version_is_one() {
assert_eq!(SOCIAL_PROTOCOL_VERSION, 1);
}
#[test]
fn post_content_with_media_urls() {
let post = SocialPostContent {
body: "Check out our new feature!".to_string(),
media_urls: vec![
"https://example.com/img1.png".to_string(),
"https://example.com/img2.png".to_string(),
],
reply_to_id: None,
};
let json = serde_json::to_string(&post).unwrap();
let parsed: SocialPostContent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.media_urls.len(), 2);
}
#[test]
fn post_content_reply_to_id() {
let post = SocialPostContent {
body: "Replying to this!".to_string(),
media_urls: vec![],
reply_to_id: Some("tweet-12345".to_string()),
};
let json = serde_json::to_string(&post).unwrap();
assert!(json.contains("reply_to_id"));
let parsed: SocialPostContent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.reply_to_id.as_deref(), Some("tweet-12345"));
}
}