1use std::io::Write;
25use std::path::{Path, PathBuf};
26use std::process::{Command, Stdio};
27use std::time::Duration;
28
29use serde::{Deserialize, Serialize};
30
31use crate::social_plugin_protocol::{
32 CreateScheduledParams, CreateSocialDraftParams, SocialDraftStatusParams, SocialHealthParams,
33 SocialPluginError, SocialPluginRequest, SocialPluginResponse, SocialPostContent,
34 SocialPostState, SOCIAL_PROTOCOL_VERSION,
35};
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct SocialPluginManifest {
44 pub name: String,
46
47 #[serde(default = "default_version")]
49 pub version: String,
50
51 #[serde(rename = "type", default = "default_type")]
53 pub plugin_type: String,
54
55 pub command: String,
57
58 #[serde(default)]
60 pub args: Vec<String>,
61
62 #[serde(default)]
66 pub capabilities: Vec<String>,
67
68 #[serde(default)]
70 pub description: Option<String>,
71
72 #[serde(default = "default_timeout_secs")]
74 pub timeout_secs: u64,
75
76 #[serde(default = "default_protocol_version")]
78 pub protocol_version: u32,
79}
80
81fn default_version() -> String {
82 "0.1.0".to_string()
83}
84
85fn default_type() -> String {
86 "social".to_string()
87}
88
89fn default_timeout_secs() -> u64 {
90 60
91}
92
93fn default_protocol_version() -> u32 {
94 SOCIAL_PROTOCOL_VERSION
95}
96
97impl SocialPluginManifest {
98 pub fn load(path: &Path) -> Result<Self, SocialPluginError> {
100 let content = std::fs::read_to_string(path)?;
101 let manifest: Self = toml::from_str(&content).map_err(|e| {
102 SocialPluginError::Io(std::io::Error::new(
103 std::io::ErrorKind::InvalidData,
104 format!("invalid manifest at {}: {}", path.display(), e),
105 ))
106 })?;
107 Ok(manifest)
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum SocialPluginSource {
118 UserGlobal,
120 ProjectLocal,
122 Path,
124}
125
126impl std::fmt::Display for SocialPluginSource {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 match self {
129 SocialPluginSource::UserGlobal => write!(f, "global"),
130 SocialPluginSource::ProjectLocal => write!(f, "project"),
131 SocialPluginSource::Path => write!(f, "PATH"),
132 }
133 }
134}
135
136#[derive(Debug, Clone)]
138pub struct DiscoveredSocialPlugin {
139 pub manifest: SocialPluginManifest,
141 pub plugin_dir: Option<PathBuf>,
143 pub source: SocialPluginSource,
145}
146
147pub fn discover_social_plugins(project_root: &Path) -> Vec<DiscoveredSocialPlugin> {
156 let mut plugins = Vec::new();
157
158 if let Some(config_dir) = user_config_dir() {
160 let global_dir = config_dir.join("ta").join("plugins").join("social");
161 scan_social_plugin_dir(&global_dir, SocialPluginSource::UserGlobal, &mut plugins);
162 }
163
164 let project_dir = project_root.join(".ta").join("plugins").join("social");
166 scan_social_plugin_dir(&project_dir, SocialPluginSource::ProjectLocal, &mut plugins);
167
168 plugins
169}
170
171fn scan_social_plugin_dir(
173 dir: &Path,
174 source: SocialPluginSource,
175 out: &mut Vec<DiscoveredSocialPlugin>,
176) {
177 if !dir.is_dir() {
178 return;
179 }
180
181 let entries = match std::fs::read_dir(dir) {
182 Ok(e) => e,
183 Err(e) => {
184 tracing::warn!(
185 dir = %dir.display(),
186 error = %e,
187 "Failed to read social plugin directory"
188 );
189 return;
190 }
191 };
192
193 for entry in entries.flatten() {
194 let path = entry.path();
195 if !path.is_dir() {
196 continue;
197 }
198
199 let manifest_path = path.join("plugin.toml");
200 if !manifest_path.exists() {
201 continue;
202 }
203
204 match SocialPluginManifest::load(&manifest_path) {
205 Ok(manifest) => {
206 tracing::debug!(
207 plugin = %manifest.name,
208 source = %source,
209 "Discovered social plugin"
210 );
211 out.push(DiscoveredSocialPlugin {
212 manifest,
213 plugin_dir: Some(path),
214 source: source.clone(),
215 });
216 }
217 Err(e) => {
218 tracing::warn!(
219 path = %manifest_path.display(),
220 error = %e,
221 "Skipping invalid social plugin manifest"
222 );
223 }
224 }
225 }
226}
227
228pub fn find_social_plugin(platform: &str, project_root: &Path) -> Option<DiscoveredSocialPlugin> {
233 let all = discover_social_plugins(project_root);
235 if let Some(p) = all.into_iter().find(|p| p.manifest.name == platform) {
236 return Some(p);
237 }
238
239 let bare_cmd = format!("ta-social-{}", platform);
241 if which_on_path(&bare_cmd) {
242 tracing::info!(
243 platform = %platform,
244 command = %bare_cmd,
245 "Found social plugin as bare executable on PATH"
246 );
247 return Some(DiscoveredSocialPlugin {
248 manifest: SocialPluginManifest {
249 name: platform.to_string(),
250 version: "unknown".to_string(),
251 plugin_type: "social".to_string(),
252 command: bare_cmd,
253 args: vec![],
254 capabilities: vec![
255 "create_draft".to_string(),
256 "create_scheduled".to_string(),
257 "draft_status".to_string(),
258 "health".to_string(),
259 ],
260 description: None,
261 timeout_secs: 60,
262 protocol_version: SOCIAL_PROTOCOL_VERSION,
263 },
264 plugin_dir: None,
265 source: SocialPluginSource::Path,
266 });
267 }
268
269 None
270}
271
272#[derive(Debug)]
281pub struct ExternalSocialAdapter {
282 command: String,
284 args: Vec<String>,
286 platform: String,
288 timeout: Duration,
290}
291
292impl ExternalSocialAdapter {
293 pub fn new(manifest: &SocialPluginManifest) -> Self {
295 Self {
296 command: manifest.command.clone(),
297 args: manifest.args.clone(),
298 platform: manifest.name.clone(),
299 timeout: Duration::from_secs(manifest.timeout_secs),
300 }
301 }
302
303 pub fn platform(&self) -> &str {
305 &self.platform
306 }
307
308 pub fn create_draft(&self, post: SocialPostContent) -> Result<String, SocialPluginError> {
315 let req = SocialPluginRequest::CreateDraft(CreateSocialDraftParams { post });
316 let resp = self.call_plugin(&req, "create_draft")?;
317 resp.draft_id
318 .ok_or_else(|| SocialPluginError::InvalidResponse {
319 name: self.platform.clone(),
320 op: "create_draft".to_string(),
321 reason: "response missing draft_id".to_string(),
322 })
323 }
324
325 pub fn create_scheduled(
331 &self,
332 post: SocialPostContent,
333 scheduled_at: &str,
334 ) -> Result<(String, String), SocialPluginError> {
335 let req = SocialPluginRequest::CreateScheduled(CreateScheduledParams {
336 post,
337 scheduled_at: scheduled_at.to_string(),
338 });
339 let resp = self.call_plugin(&req, "create_scheduled")?;
340 let id = resp
341 .scheduled_id
342 .ok_or_else(|| SocialPluginError::InvalidResponse {
343 name: self.platform.clone(),
344 op: "create_scheduled".to_string(),
345 reason: "response missing scheduled_id".to_string(),
346 })?;
347 let at = resp
348 .scheduled_at
349 .unwrap_or_else(|| scheduled_at.to_string());
350 Ok((id, at))
351 }
352
353 pub fn draft_status(&self, draft_id: &str) -> Result<SocialPostState, SocialPluginError> {
355 let req = SocialPluginRequest::DraftStatus(SocialDraftStatusParams {
356 draft_id: draft_id.to_string(),
357 });
358 let resp = self.call_plugin(&req, "draft_status")?;
359 Ok(resp.state.unwrap_or(SocialPostState::Unknown))
360 }
361
362 pub fn health(&self) -> Result<(String, String), SocialPluginError> {
366 let req = SocialPluginRequest::Health(SocialHealthParams {});
367 let resp = self.call_plugin(&req, "health")?;
368 let handle = resp.handle.unwrap_or_else(|| "<unknown>".to_string());
369 let provider = resp.provider.unwrap_or_else(|| self.platform.clone());
370 Ok((handle, provider))
371 }
372
373 fn call_plugin(
378 &self,
379 req: &SocialPluginRequest,
380 op: &str,
381 ) -> Result<SocialPluginResponse, SocialPluginError> {
382 let req_json = serde_json::to_string(req)?;
383
384 let mut parts = self.command.split_whitespace();
385 let program = parts.next().ok_or_else(|| SocialPluginError::SpawnFailed {
386 command: self.command.clone(),
387 reason: "command string is empty".to_string(),
388 })?;
389
390 let mut cmd = Command::new(program);
391 for arg in parts {
392 cmd.arg(arg);
393 }
394 for arg in &self.args {
395 cmd.arg(arg);
396 }
397 cmd.stdin(Stdio::piped())
398 .stdout(Stdio::piped())
399 .stderr(Stdio::piped());
400
401 let mut child = cmd.spawn().map_err(|e| SocialPluginError::SpawnFailed {
402 command: self.command.clone(),
403 reason: e.to_string(),
404 })?;
405
406 if let Some(mut stdin) = child.stdin.take() {
408 stdin
409 .write_all(req_json.as_bytes())
410 .and_then(|_| stdin.write_all(b"\n"))
411 .map_err(|e| {
412 SocialPluginError::Io(std::io::Error::new(
413 e.kind(),
414 format!("failed to write to plugin stdin: {}", e),
415 ))
416 })?;
417 }
418
419 let timeout_ms = self.timeout.as_millis() as u64;
421 let output =
422 wait_with_timeout(child, timeout_ms).map_err(|_| SocialPluginError::Timeout {
423 name: self.platform.clone(),
424 op: op.to_string(),
425 timeout_secs: self.timeout.as_secs(),
426 })?;
427
428 if !output.status.success() {
429 let stderr = String::from_utf8_lossy(&output.stderr);
430 return Err(SocialPluginError::OpFailed {
431 name: self.platform.clone(),
432 op: op.to_string(),
433 reason: format!(
434 "plugin exited with status {}. stderr: {}",
435 output.status,
436 stderr.trim()
437 ),
438 });
439 }
440
441 let stdout = String::from_utf8_lossy(&output.stdout);
442 let first_line = stdout.lines().next().unwrap_or("").trim();
443
444 if first_line.is_empty() {
445 return Err(SocialPluginError::InvalidResponse {
446 name: self.platform.clone(),
447 op: op.to_string(),
448 reason: "plugin produced no output (expected one JSON line)".to_string(),
449 });
450 }
451
452 let resp: SocialPluginResponse =
453 serde_json::from_str(first_line).map_err(|e| SocialPluginError::InvalidResponse {
454 name: self.platform.clone(),
455 op: op.to_string(),
456 reason: format!(
457 "invalid JSON: {}. Got: '{}'",
458 e,
459 if first_line.len() > 200 {
460 &first_line[..200]
461 } else {
462 first_line
463 }
464 ),
465 })?;
466
467 if !resp.ok {
468 return Err(SocialPluginError::OpFailed {
469 name: self.platform.clone(),
470 op: op.to_string(),
471 reason: resp
472 .error
473 .unwrap_or_else(|| "plugin returned ok=false".to_string()),
474 });
475 }
476
477 Ok(resp)
478 }
479}
480
481#[derive(Debug, Clone)]
487pub struct SocialSupervisorResult {
488 pub passed: bool,
490 pub flag_reason: Option<String>,
492 pub confidence: f64,
494}
495
496#[derive(Debug, Clone, Default)]
498pub struct SocialSupervisorConfig {
499 pub min_confidence: f64,
501 pub flag_if_contains: Vec<String>,
503 pub check_unverified_claims: bool,
505 pub blocked_client_names: Vec<String>,
507}
508
509pub fn social_supervisor_check(
517 body: &str,
518 confidence: f64,
519 config: &SocialSupervisorConfig,
520 allow_client_names: bool,
521) -> SocialSupervisorResult {
522 if confidence < config.min_confidence {
524 return SocialSupervisorResult {
525 passed: false,
526 flag_reason: Some(format!(
527 "supervisor confidence {:.2} below threshold {:.2}",
528 confidence, config.min_confidence
529 )),
530 confidence,
531 };
532 }
533
534 let body_lower = body.to_lowercase();
536 for phrase in &config.flag_if_contains {
537 if body_lower.contains(&phrase.to_lowercase()) {
538 return SocialSupervisorResult {
539 passed: false,
540 flag_reason: Some(format!("post body contains flagged phrase: '{}'", phrase)),
541 confidence,
542 };
543 }
544 }
545
546 if !allow_client_names {
548 for client in &config.blocked_client_names {
549 if body_lower.contains(&client.to_lowercase()) {
550 return SocialSupervisorResult {
551 passed: false,
552 flag_reason: Some(format!(
553 "post body contains client name '{}' (not allowed without explicit permission)",
554 client
555 )),
556 confidence,
557 };
558 }
559 }
560 }
561
562 if config.check_unverified_claims {
564 let claim_patterns = [
565 "guaranteed to",
566 "100% proven",
567 "scientifically proven",
568 "always works",
569 "never fails",
570 "zero risk",
571 ];
572 for pattern in &claim_patterns {
573 if body_lower.contains(pattern) {
574 return SocialSupervisorResult {
575 passed: false,
576 flag_reason: Some(format!(
577 "post body contains potentially unverified claim: '{}'",
578 pattern
579 )),
580 confidence,
581 };
582 }
583 }
584 }
585
586 SocialSupervisorResult {
587 passed: true,
588 flag_reason: None,
589 confidence,
590 }
591}
592
593fn which_on_path(name: &str) -> bool {
599 std::env::var_os("PATH")
600 .map(|path_var| std::env::split_paths(&path_var).any(|dir| dir.join(name).is_file()))
601 .unwrap_or(false)
602}
603
604fn user_config_dir() -> Option<PathBuf> {
606 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
607 return Some(PathBuf::from(xdg));
608 }
609 std::env::var("HOME")
610 .ok()
611 .map(|home| PathBuf::from(home).join(".config"))
612}
613
614fn wait_with_timeout(
616 child: std::process::Child,
617 timeout_ms: u64,
618) -> std::result::Result<std::process::Output, String> {
619 use std::sync::mpsc;
620
621 let child_id = child.id();
622 let (tx, rx) = mpsc::channel::<()>();
623
624 let watchdog =
625 std::thread::spawn(
626 move || match rx.recv_timeout(Duration::from_millis(timeout_ms)) {
627 Ok(()) => {}
628 Err(_) => {
629 #[cfg(unix)]
630 unsafe {
631 libc::kill(child_id as libc::pid_t, libc::SIGKILL);
632 }
633 #[cfg(not(unix))]
634 let _ = child_id;
635 }
636 },
637 );
638
639 let output = child
640 .wait_with_output()
641 .map_err(|e| format!("wait_with_output failed: {}", e))?;
642
643 let _ = tx.send(());
644 let _ = watchdog.join();
645
646 Ok(output)
647}
648
649#[cfg(test)]
654mod tests {
655 use super::*;
656 use std::path::Path;
657
658 fn write_manifest(dir: &Path, content: &str) {
659 std::fs::write(dir.join("plugin.toml"), content).unwrap();
660 }
661
662 #[test]
663 fn discover_social_plugins_finds_manifests() {
664 let root = tempfile::tempdir().unwrap();
665 let social_dir = root.path().join(".ta").join("plugins").join("social");
666
667 let linkedin_dir = social_dir.join("linkedin");
668 std::fs::create_dir_all(&linkedin_dir).unwrap();
669 write_manifest(
670 &linkedin_dir,
671 r#"
672name = "linkedin"
673version = "0.1.0"
674type = "social"
675command = "ta-social-linkedin"
676capabilities = ["create_draft", "create_scheduled", "draft_status", "health"]
677description = "LinkedIn social media adapter"
678"#,
679 );
680
681 let plugins = discover_social_plugins(root.path());
682 assert_eq!(plugins.len(), 1);
683 assert_eq!(plugins[0].manifest.name, "linkedin");
684 assert_eq!(plugins[0].source, SocialPluginSource::ProjectLocal);
685 }
686
687 #[test]
688 fn discover_social_plugins_skips_invalid_manifest() {
689 let root = tempfile::tempdir().unwrap();
690 let social_dir = root.path().join(".ta").join("plugins").join("social");
691
692 let good_dir = social_dir.join("linkedin");
693 std::fs::create_dir_all(&good_dir).unwrap();
694 write_manifest(
695 &good_dir,
696 r#"name = "linkedin"
697type = "social"
698command = "ta-social-linkedin"
699"#,
700 );
701
702 let bad_dir = social_dir.join("bad");
703 std::fs::create_dir_all(&bad_dir).unwrap();
704 std::fs::write(bad_dir.join("plugin.toml"), "{{not valid toml}}").unwrap();
705
706 let plugins = discover_social_plugins(root.path());
707 assert_eq!(plugins.len(), 1);
708 assert_eq!(plugins[0].manifest.name, "linkedin");
709 }
710
711 #[test]
712 fn discover_social_plugins_empty_dir_returns_empty() {
713 let root = tempfile::tempdir().unwrap();
714 let plugins = discover_social_plugins(root.path());
715 assert!(plugins.is_empty());
716 }
717
718 #[test]
719 fn find_social_plugin_project_local() {
720 let root = tempfile::tempdir().unwrap();
721 let social_dir = root.path().join(".ta").join("plugins").join("social");
722
723 let x_dir = social_dir.join("x");
724 std::fs::create_dir_all(&x_dir).unwrap();
725 write_manifest(
726 &x_dir,
727 r#"name = "x"
728type = "social"
729command = "ta-social-x"
730"#,
731 );
732
733 let found = find_social_plugin("x", root.path());
734 assert!(found.is_some());
735 assert_eq!(found.unwrap().manifest.name, "x");
736 }
737
738 #[test]
739 fn find_social_plugin_missing_returns_none() {
740 let root = tempfile::tempdir().unwrap();
741 let found = find_social_plugin("nonexistent-platform", root.path());
742 assert!(found.is_none());
743 }
744
745 #[test]
746 fn social_plugin_source_display() {
747 assert_eq!(SocialPluginSource::UserGlobal.to_string(), "global");
748 assert_eq!(SocialPluginSource::ProjectLocal.to_string(), "project");
749 assert_eq!(SocialPluginSource::Path.to_string(), "PATH");
750 }
751
752 #[test]
753 fn supervisor_check_passes_clean_content() {
754 let config = SocialSupervisorConfig {
755 min_confidence: 0.8,
756 flag_if_contains: vec!["I promise".to_string()],
757 check_unverified_claims: true,
758 blocked_client_names: vec!["AcmeCorp".to_string()],
759 };
760 let result = social_supervisor_check(
761 "Excited to share our new AI pipeline feature!",
762 0.95,
763 &config,
764 false,
765 );
766 assert!(result.passed);
767 assert!(result.flag_reason.is_none());
768 }
769
770 #[test]
771 fn supervisor_check_fails_low_confidence() {
772 let config = SocialSupervisorConfig {
773 min_confidence: 0.8,
774 ..Default::default()
775 };
776 let result = social_supervisor_check("Good content here", 0.5, &config, false);
777 assert!(!result.passed);
778 assert!(result.flag_reason.unwrap().contains("below threshold"));
779 }
780
781 #[test]
782 fn supervisor_check_fails_flag_phrase() {
783 let config = SocialSupervisorConfig {
784 min_confidence: 0.0,
785 flag_if_contains: vec!["I promise".to_string()],
786 ..Default::default()
787 };
788 let result =
789 social_supervisor_check("I promise this will work perfectly.", 1.0, &config, false);
790 assert!(!result.passed);
791 assert!(result.flag_reason.unwrap().contains("I promise"));
792 }
793
794 #[test]
795 fn supervisor_check_fails_client_name() {
796 let config = SocialSupervisorConfig {
797 min_confidence: 0.0,
798 blocked_client_names: vec!["SecretClient".to_string()],
799 ..Default::default()
800 };
801 let result = social_supervisor_check(
802 "Working with SecretClient on this amazing project!",
803 1.0,
804 &config,
805 false,
806 );
807 assert!(!result.passed);
808 assert!(result.flag_reason.unwrap().contains("client name"));
809 }
810
811 #[test]
812 fn supervisor_check_allows_client_name_when_permitted() {
813 let config = SocialSupervisorConfig {
814 min_confidence: 0.0,
815 blocked_client_names: vec!["SecretClient".to_string()],
816 ..Default::default()
817 };
818 let result = social_supervisor_check(
819 "Working with SecretClient on this amazing project!",
820 1.0,
821 &config,
822 true, );
824 assert!(result.passed);
825 }
826
827 #[test]
828 fn supervisor_check_fails_unverified_claim() {
829 let config = SocialSupervisorConfig {
830 min_confidence: 0.0,
831 check_unverified_claims: true,
832 ..Default::default()
833 };
834 let result = social_supervisor_check(
835 "This is guaranteed to increase your revenue by 500%!",
836 1.0,
837 &config,
838 false,
839 );
840 assert!(!result.passed);
841 assert!(result.flag_reason.unwrap().contains("unverified claim"));
842 }
843
844 #[cfg(unix)]
846 fn shared_mock_social_plugin_path() -> &'static std::path::Path {
847 use std::io::Write as W;
848 use std::os::unix::fs::PermissionsExt;
849 use std::sync::OnceLock;
850
851 static MOCK_PATH: OnceLock<std::path::PathBuf> = OnceLock::new();
852 MOCK_PATH.get_or_init(|| {
853 let pid = std::process::id();
854 let name = format!("ta-social-mock-shared-{}", pid);
855
856 #[cfg(target_os = "linux")]
857 let path = {
858 let shm = std::path::Path::new("/dev/shm");
859 if shm.exists() {
860 shm.join(&name)
861 } else {
862 std::path::PathBuf::from("/tmp").join(&name)
863 }
864 };
865 #[cfg(not(target_os = "linux"))]
866 let path = std::env::temp_dir().join(&name);
867
868 let mut f = std::fs::File::create(&path).unwrap();
869 f.write_all(
870 br#"#!/bin/sh
871read -r line
872case "$line" in
873 *create_draft*) echo '{"ok":true,"draft_id":"linkedin-draft-abc123"}' ;;
874 *create_scheduled*) echo '{"ok":true,"scheduled_id":"buffer-post-xyz","scheduled_at":"2026-04-07T14:00:00Z"}' ;;
875 *) echo '{"ok":true,"handle":"@testuser","provider":"mock"}' ;;
876esac
877"#,
878 )
879 .unwrap();
880 f.sync_all().unwrap();
881 drop(f);
882
883 let mut perms = std::fs::metadata(&path).unwrap().permissions();
884 perms.set_mode(0o755);
885 std::fs::set_permissions(&path, perms).unwrap();
886 let _ = std::fs::metadata(&path).unwrap();
887 path
888 })
889 }
890
891 #[cfg(unix)]
892 #[test]
893 fn external_adapter_health_returns_handle() {
894 let plugin_path = shared_mock_social_plugin_path();
895 let manifest = SocialPluginManifest {
896 name: "mock".to_string(),
897 version: "0.1.0".to_string(),
898 plugin_type: "social".to_string(),
899 command: plugin_path.display().to_string(),
900 args: vec![],
901 capabilities: vec!["health".to_string()],
902 description: None,
903 timeout_secs: 30,
904 protocol_version: SOCIAL_PROTOCOL_VERSION,
905 };
906
907 let adapter = ExternalSocialAdapter::new(&manifest);
908 let (handle, provider) = adapter.health().unwrap();
909 assert_eq!(handle, "@testuser");
910 assert_eq!(provider, "mock");
911 }
912
913 #[cfg(unix)]
914 #[test]
915 fn external_adapter_create_draft_returns_id() {
916 let plugin_path = shared_mock_social_plugin_path();
917 let manifest = SocialPluginManifest {
918 name: "mock".to_string(),
919 version: "0.1.0".to_string(),
920 plugin_type: "social".to_string(),
921 command: plugin_path.display().to_string(),
922 args: vec![],
923 capabilities: vec!["create_draft".to_string()],
924 description: None,
925 timeout_secs: 30,
926 protocol_version: SOCIAL_PROTOCOL_VERSION,
927 };
928
929 let adapter = ExternalSocialAdapter::new(&manifest);
930 let draft_id = adapter
931 .create_draft(SocialPostContent {
932 body: "Excited to share this!".to_string(),
933 media_urls: vec![],
934 reply_to_id: None,
935 })
936 .unwrap();
937 assert_eq!(draft_id, "linkedin-draft-abc123");
938 }
939
940 #[cfg(unix)]
941 #[test]
942 fn external_adapter_create_scheduled_returns_id_and_time() {
943 let plugin_path = shared_mock_social_plugin_path();
944 let manifest = SocialPluginManifest {
945 name: "mock".to_string(),
946 version: "0.1.0".to_string(),
947 plugin_type: "social".to_string(),
948 command: plugin_path.display().to_string(),
949 args: vec![],
950 capabilities: vec!["create_scheduled".to_string()],
951 description: None,
952 timeout_secs: 30,
953 protocol_version: SOCIAL_PROTOCOL_VERSION,
954 };
955
956 let adapter = ExternalSocialAdapter::new(&manifest);
957 let (scheduled_id, scheduled_at) = adapter
958 .create_scheduled(
959 SocialPostContent {
960 body: "Scheduled post content".to_string(),
961 media_urls: vec![],
962 reply_to_id: None,
963 },
964 "2026-04-07T14:00:00Z",
965 )
966 .unwrap();
967 assert_eq!(scheduled_id, "buffer-post-xyz");
968 assert_eq!(scheduled_at, "2026-04-07T14:00:00Z");
969 }
970}