Skip to main content

ta_submit/
social_adapter.rs

1//! Social media adapter plugin discovery and external plugin wrapper.
2//!
3//! ## Plugin discovery
4//!
5//! Plugins are searched in order:
6//! 1. `~/.config/ta/plugins/social/` — user-global
7//! 2. `.ta/plugins/social/` — project-local
8//! 3. `$PATH` — bare executable with prefix `ta-social-`
9//!
10//! The first matching plugin for the given platform name is used.
11//!
12//! ## ExternalSocialAdapter
13//!
14//! Wraps an external plugin process and translates calls into
15//! JSON-over-stdio request/response exchanges. Each method call spawns
16//! a fresh process (plugins are stateless per-call).
17//!
18//! ## Credentials
19//!
20//! Credentials (OAuth2 tokens) are stored in the OS keychain under the
21//! key `ta-social:<platform>:<handle>`. Plugins retrieve them via
22//! `ta adapter credentials get <key>`.
23
24use 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// ---------------------------------------------------------------------------
38// Plugin manifest
39// ---------------------------------------------------------------------------
40
41/// Parsed `plugin.toml` manifest for a social media adapter plugin.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct SocialPluginManifest {
44    /// Platform name (e.g., "linkedin", "x", "buffer").
45    pub name: String,
46
47    /// Plugin version (semver).
48    #[serde(default = "default_version")]
49    pub version: String,
50
51    /// Plugin type — must be `"social"`.
52    #[serde(rename = "type", default = "default_type")]
53    pub plugin_type: String,
54
55    /// Executable command to spawn.
56    pub command: String,
57
58    /// Additional arguments passed on every invocation.
59    #[serde(default)]
60    pub args: Vec<String>,
61
62    /// Capabilities this plugin exposes.
63    ///
64    /// Standard values: `"create_draft"`, `"create_scheduled"`, `"draft_status"`, `"health"`.
65    #[serde(default)]
66    pub capabilities: Vec<String>,
67
68    /// Human-readable description.
69    #[serde(default)]
70    pub description: Option<String>,
71
72    /// Per-call timeout in seconds.
73    #[serde(default = "default_timeout_secs")]
74    pub timeout_secs: u64,
75
76    /// Protocol version this plugin implements.
77    #[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    /// Load a manifest from a `plugin.toml` file.
99    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// ---------------------------------------------------------------------------
112// Discovery
113// ---------------------------------------------------------------------------
114
115/// Where a social plugin was discovered from.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum SocialPluginSource {
118    /// `~/.config/ta/plugins/social/` (user-global).
119    UserGlobal,
120    /// `.ta/plugins/social/` in the project root.
121    ProjectLocal,
122    /// Bare executable on `$PATH` (prefix `ta-social-`).
123    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/// A discovered social media plugin with its manifest and origin.
137#[derive(Debug, Clone)]
138pub struct DiscoveredSocialPlugin {
139    /// Parsed manifest.
140    pub manifest: SocialPluginManifest,
141    /// Directory containing `plugin.toml` (None for PATH-discovered plugins).
142    pub plugin_dir: Option<PathBuf>,
143    /// Discovery source.
144    pub source: SocialPluginSource,
145}
146
147/// Discover all social media adapter plugins.
148///
149/// Resolution order:
150/// 1. `~/.config/ta/plugins/social/` — user-global (highest priority)
151/// 2. `.ta/plugins/social/` — project-local
152///
153/// PATH discovery (`ta-social-<name>`) is performed on-demand in
154/// [`find_social_plugin`] when a named plugin is not found above.
155pub fn discover_social_plugins(project_root: &Path) -> Vec<DiscoveredSocialPlugin> {
156    let mut plugins = Vec::new();
157
158    // 1. User-global
159    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    // 2. Project-local
165    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
171/// Scan a directory for social plugin subdirectories containing `plugin.toml`.
172fn 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
228/// Find a social plugin by platform name.
229///
230/// Searches user-global → project-local → PATH.
231/// Returns `None` if no plugin is found for the given platform.
232pub fn find_social_plugin(platform: &str, project_root: &Path) -> Option<DiscoveredSocialPlugin> {
233    // Search manifest-based plugins.
234    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    // Fall back to bare PATH executable: `ta-social-<name>`.
240    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// ---------------------------------------------------------------------------
273// ExternalSocialAdapter
274// ---------------------------------------------------------------------------
275
276/// Social media adapter that delegates all operations to an external plugin process.
277///
278/// Each method call spawns a fresh process, sends one JSON request line to
279/// stdin, reads one JSON response line from stdout, then waits for exit.
280#[derive(Debug)]
281pub struct ExternalSocialAdapter {
282    /// Plugin command to spawn.
283    command: String,
284    /// Additional pre-configured args.
285    args: Vec<String>,
286    /// Platform name (from manifest).
287    platform: String,
288    /// Per-call timeout.
289    timeout: Duration,
290}
291
292impl ExternalSocialAdapter {
293    /// Create a new adapter from a discovered plugin manifest.
294    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    /// Platform name (e.g., "linkedin", "x", "buffer").
304    pub fn platform(&self) -> &str {
305        &self.platform
306    }
307
308    /// Create a draft in the platform's native draft state.
309    ///
310    /// Returns the platform-assigned draft ID (e.g., "linkedin-draft-abc123").
311    ///
312    /// NOTE: There is intentionally no `publish` method on this type.
313    /// TA never publishes social media posts on behalf of the user.
314    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    /// Schedule a post at a future time.
326    ///
327    /// Returns `(scheduled_id, confirmed_scheduled_at)`.
328    ///
329    /// The platform (or its scheduler) controls the actual publication.
330    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    /// Poll the current state of a previously created draft or scheduled post.
354    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    /// Run a health check: verify credentials and connectivity.
363    ///
364    /// Returns `(handle, provider_name)` on success.
365    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    // -----------------------------------------------------------------------
374    // Internal
375    // -----------------------------------------------------------------------
376
377    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        // Write request to stdin.
407        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        // Wait with timeout.
420        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// ---------------------------------------------------------------------------
482// Supervisor check for social content
483// ---------------------------------------------------------------------------
484
485/// Result of a social content supervisor check.
486#[derive(Debug, Clone)]
487pub struct SocialSupervisorResult {
488    /// Whether the content passed all checks.
489    pub passed: bool,
490    /// Human-readable reason for flagging (None if passed).
491    pub flag_reason: Option<String>,
492    /// Confidence score [0.0, 1.0].
493    pub confidence: f64,
494}
495
496/// Social supervisor configuration.
497#[derive(Debug, Clone, Default)]
498pub struct SocialSupervisorConfig {
499    /// Confidence below this threshold → review queue.
500    pub min_confidence: f64,
501    /// Substrings that trigger a flag if found in the post body.
502    pub flag_if_contains: Vec<String>,
503    /// Whether to check for patterns that look like unverified claims.
504    pub check_unverified_claims: bool,
505    /// Client names that must NOT appear unless explicitly allowed.
506    pub blocked_client_names: Vec<String>,
507}
508
509/// Check social media post content against the supervisor policy.
510///
511/// Checks:
512/// - `confidence >= min_confidence`
513/// - No `flag_if_contains` substring appears in the post body
514/// - No blocked client names in the post body (unless `allow_client_names` is true)
515/// - Optionally checks for patterns that look like unverified claims
516pub fn social_supervisor_check(
517    body: &str,
518    confidence: f64,
519    config: &SocialSupervisorConfig,
520    allow_client_names: bool,
521) -> SocialSupervisorResult {
522    // 1. Confidence threshold check.
523    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    // 2. flag_if_contains checks.
535    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    // 3. Blocked client names.
547    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    // 4. Unverified claims check (heuristic).
563    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
593// ---------------------------------------------------------------------------
594// Utilities
595// ---------------------------------------------------------------------------
596
597/// Check whether a binary exists on PATH.
598fn 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
604/// Get the user's config directory.
605fn 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
614/// Wait for a child process to exit, killing it after `timeout_ms` milliseconds.
615fn 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// ---------------------------------------------------------------------------
650// Tests
651// ---------------------------------------------------------------------------
652
653#[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, // explicitly allowed
823        );
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    /// Return the path to a shared mock social plugin binary.
845    #[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}