1use serde::{Deserialize, Serialize};
35
36pub const SOCIAL_PROTOCOL_VERSION: u32 = 1;
38
39#[derive(Debug, Serialize, Deserialize, PartialEq)]
51#[serde(tag = "op", rename_all = "snake_case")]
52pub enum SocialPluginRequest {
53 CreateDraft(CreateSocialDraftParams),
57
58 CreateScheduled(CreateScheduledParams),
63
64 DraftStatus(SocialDraftStatusParams),
66
67 Health(SocialHealthParams),
69
70 Capabilities(SocialCapabilitiesParams),
72}
73
74#[derive(Debug, Serialize, Deserialize)]
83pub struct SocialPluginResponse {
84 pub ok: bool,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub error: Option<String>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub draft_id: Option<String>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub scheduled_id: Option<String>,
98
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub scheduled_at: Option<String>,
102
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub state: Option<SocialPostState>,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub handle: Option<String>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub provider: Option<String>,
114
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub capabilities: Option<Vec<String>>,
118}
119
120impl SocialPluginResponse {
121 pub fn ok() -> Self {
123 Self {
124 ok: true,
125 error: None,
126 draft_id: None,
127 scheduled_id: None,
128 scheduled_at: None,
129 state: None,
130 handle: None,
131 provider: None,
132 capabilities: None,
133 }
134 }
135
136 pub fn error(msg: impl Into<String>) -> Self {
138 Self {
139 ok: false,
140 error: Some(msg.into()),
141 draft_id: None,
142 scheduled_id: None,
143 scheduled_at: None,
144 state: None,
145 handle: None,
146 provider: None,
147 capabilities: None,
148 }
149 }
150}
151
152#[derive(Debug, Serialize, Deserialize, PartialEq)]
165pub struct CreateSocialDraftParams {
166 pub post: SocialPostContent,
168}
169
170#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
172pub struct SocialPostContent {
173 pub body: String,
175
176 #[serde(default)]
178 pub media_urls: Vec<String>,
179
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub reply_to_id: Option<String>,
183}
184
185#[derive(Debug, Serialize, Deserialize, PartialEq)]
194pub struct CreateScheduledParams {
195 pub post: SocialPostContent,
197
198 pub scheduled_at: String,
200}
201
202#[derive(Debug, Serialize, Deserialize, PartialEq)]
208pub struct SocialDraftStatusParams {
209 pub draft_id: String,
211}
212
213#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
215#[serde(rename_all = "snake_case")]
216pub enum SocialPostState {
217 Draft,
219 Published,
221 Deleted,
223 Unknown,
225}
226
227impl std::fmt::Display for SocialPostState {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 match self {
230 SocialPostState::Draft => write!(f, "draft"),
231 SocialPostState::Published => write!(f, "published"),
232 SocialPostState::Deleted => write!(f, "deleted"),
233 SocialPostState::Unknown => write!(f, "unknown"),
234 }
235 }
236}
237
238#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
246pub struct SocialHealthParams {}
247
248#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
256pub struct SocialCapabilitiesParams {}
257
258#[derive(Debug, thiserror::Error)]
264pub enum SocialPluginError {
265 #[error("social plugin not found: {name}. Install with: ta adapter setup social/{name}")]
266 PluginNotFound { name: String },
267
268 #[error("social plugin '{name}' op '{op}' failed: {reason}")]
269 OpFailed {
270 name: String,
271 op: String,
272 reason: String,
273 },
274
275 #[error("social plugin '{name}' produced invalid response for op '{op}': {reason}")]
276 InvalidResponse {
277 name: String,
278 op: String,
279 reason: String,
280 },
281
282 #[error("failed to spawn social plugin '{command}': {reason}. Ensure the plugin is on PATH.")]
283 SpawnFailed { command: String, reason: String },
284
285 #[error("social plugin '{name}' timed out after {timeout_secs}s for op '{op}'. Increase timeout in plugin.toml.")]
286 Timeout {
287 name: String,
288 op: String,
289 timeout_secs: u64,
290 },
291
292 #[error("I/O error: {0}")]
293 Io(#[from] std::io::Error),
294
295 #[error("JSON error: {0}")]
296 Json(#[from] serde_json::Error),
297}
298
299#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn create_draft_request_roundtrip() {
309 let req = SocialPluginRequest::CreateDraft(CreateSocialDraftParams {
310 post: SocialPostContent {
311 body: "Excited to announce the cinepipe launch! 🎬".to_string(),
312 media_urls: vec![],
313 reply_to_id: None,
314 },
315 });
316 let json = serde_json::to_string(&req).unwrap();
317 assert!(json.contains("\"op\":\"create_draft\""));
318 let parsed: SocialPluginRequest = serde_json::from_str(&json).unwrap();
319 assert_eq!(parsed, req);
320 }
321
322 #[test]
323 fn create_scheduled_request_roundtrip() {
324 let req = SocialPluginRequest::CreateScheduled(CreateScheduledParams {
325 post: SocialPostContent {
326 body: "Week 1 of our public alpha is live!".to_string(),
327 media_urls: vec!["https://example.com/screenshot.png".to_string()],
328 reply_to_id: None,
329 },
330 scheduled_at: "2026-04-07T14:00:00Z".to_string(),
331 });
332 let json = serde_json::to_string(&req).unwrap();
333 assert!(json.contains("\"op\":\"create_scheduled\""));
334 assert!(json.contains("2026-04-07T14:00:00Z"));
335 let parsed: SocialPluginRequest = serde_json::from_str(&json).unwrap();
336 assert_eq!(parsed, req);
337 }
338
339 #[test]
340 fn no_publish_op_variant() {
341 let req = SocialPluginRequest::Health(SocialHealthParams {});
343 let json = serde_json::to_string(&req).unwrap();
344 assert!(
345 !json.contains("\"publish\""),
346 "Publish op must not exist in the social protocol"
347 );
348 }
349
350 #[test]
351 fn draft_status_request_roundtrip() {
352 let req = SocialPluginRequest::DraftStatus(SocialDraftStatusParams {
353 draft_id: "linkedin-draft-xyz".to_string(),
354 });
355 let json = serde_json::to_string(&req).unwrap();
356 assert!(json.contains("\"op\":\"draft_status\""));
357 let parsed: SocialPluginRequest = serde_json::from_str(&json).unwrap();
358 assert_eq!(parsed, req);
359 }
360
361 #[test]
362 fn health_request_roundtrip() {
363 let req = SocialPluginRequest::Health(SocialHealthParams {});
364 let json = serde_json::to_string(&req).unwrap();
365 assert!(json.contains("\"op\":\"health\""));
366 let parsed: SocialPluginRequest = serde_json::from_str(&json).unwrap();
367 assert_eq!(parsed, req);
368 }
369
370 #[test]
371 fn response_ok_roundtrip() {
372 let resp = SocialPluginResponse::ok();
373 let json = serde_json::to_string(&resp).unwrap();
374 let parsed: SocialPluginResponse = serde_json::from_str(&json).unwrap();
375 assert!(parsed.ok);
376 assert!(parsed.error.is_none());
377 }
378
379 #[test]
380 fn response_error_roundtrip() {
381 let resp = SocialPluginResponse::error("credentials not found");
382 let json = serde_json::to_string(&resp).unwrap();
383 let parsed: SocialPluginResponse = serde_json::from_str(&json).unwrap();
384 assert!(!parsed.ok);
385 assert_eq!(parsed.error.as_deref(), Some("credentials not found"));
386 }
387
388 #[test]
389 fn response_with_draft_id() {
390 let mut resp = SocialPluginResponse::ok();
391 resp.draft_id = Some("linkedin-draft-abc123".to_string());
392 let json = serde_json::to_string(&resp).unwrap();
393 let parsed: SocialPluginResponse = serde_json::from_str(&json).unwrap();
394 assert_eq!(parsed.draft_id.as_deref(), Some("linkedin-draft-abc123"));
395 }
396
397 #[test]
398 fn response_with_scheduled_id() {
399 let mut resp = SocialPluginResponse::ok();
400 resp.scheduled_id = Some("buffer-post-xyz".to_string());
401 resp.scheduled_at = Some("2026-04-07T14:00:00Z".to_string());
402 let json = serde_json::to_string(&resp).unwrap();
403 let parsed: SocialPluginResponse = serde_json::from_str(&json).unwrap();
404 assert_eq!(parsed.scheduled_id.as_deref(), Some("buffer-post-xyz"));
405 assert_eq!(parsed.scheduled_at.as_deref(), Some("2026-04-07T14:00:00Z"));
406 }
407
408 #[test]
409 fn post_state_display() {
410 assert_eq!(SocialPostState::Draft.to_string(), "draft");
411 assert_eq!(SocialPostState::Published.to_string(), "published");
412 assert_eq!(SocialPostState::Deleted.to_string(), "deleted");
413 assert_eq!(SocialPostState::Unknown.to_string(), "unknown");
414 }
415
416 #[test]
417 fn post_state_roundtrip() {
418 for state in [
419 SocialPostState::Draft,
420 SocialPostState::Published,
421 SocialPostState::Deleted,
422 SocialPostState::Unknown,
423 ] {
424 let json = serde_json::to_string(&state).unwrap();
425 let parsed: SocialPostState = serde_json::from_str(&json).unwrap();
426 assert_eq!(parsed, state);
427 }
428 }
429
430 #[test]
431 fn social_protocol_version_is_one() {
432 assert_eq!(SOCIAL_PROTOCOL_VERSION, 1);
433 }
434
435 #[test]
436 fn post_content_with_media_urls() {
437 let post = SocialPostContent {
438 body: "Check out our new feature!".to_string(),
439 media_urls: vec![
440 "https://example.com/img1.png".to_string(),
441 "https://example.com/img2.png".to_string(),
442 ],
443 reply_to_id: None,
444 };
445 let json = serde_json::to_string(&post).unwrap();
446 let parsed: SocialPostContent = serde_json::from_str(&json).unwrap();
447 assert_eq!(parsed.media_urls.len(), 2);
448 }
449
450 #[test]
451 fn post_content_reply_to_id() {
452 let post = SocialPostContent {
453 body: "Replying to this!".to_string(),
454 media_urls: vec![],
455 reply_to_id: Some("tweet-12345".to_string()),
456 };
457 let json = serde_json::to_string(&post).unwrap();
458 assert!(json.contains("reply_to_id"));
459 let parsed: SocialPostContent = serde_json::from_str(&json).unwrap();
460 assert_eq!(parsed.reply_to_id.as_deref(), Some("tweet-12345"));
461 }
462}