Skip to main content

ta_submit/
external_vcs_adapter.rs

1//! External VCS adapter — SourceAdapter implementation over JSON-over-stdio.
2//!
3//! Wraps an external VCS plugin process and implements the `SourceAdapter`
4//! trait by translating every method call into a single JSON-over-stdio
5//! request/response exchange.
6//!
7//! ## Lifecycle
8//!
9//! For each method call, `ExternalVcsAdapter`:
10//!
11//! 1. Spawns the plugin process (fresh per call — plugins are stateless).
12//! 2. Sends a `VcsPluginRequest` JSON line to the process's stdin.
13//! 3. Reads the `VcsPluginResponse` JSON line from stdout.
14//! 4. Waits for the process to exit.
15//! 5. Returns the parsed result or a `SubmitError` on failure.
16//!
17//! ## Handshake
18//!
19//! The first call in `ExternalVcsAdapter::new()` performs a `handshake` to
20//! validate protocol compatibility. An incompatible protocol version causes
21//! construction to fail with `SubmitError::ConfigError`.
22
23use std::io::Write;
24use std::path::{Path, PathBuf};
25use std::process::{Command, Stdio};
26use std::time::Duration;
27
28use ta_changeset::DraftPackage;
29use ta_goal::GoalRun;
30
31use crate::adapter::{
32    CommitResult, MergeResult, PushResult, Result, ReviewResult, ReviewStatus, SavedVcsState,
33    SourceAdapter, SubmitError, SyncResult,
34};
35use crate::config::SubmitConfig;
36use crate::vcs_plugin_manifest::VcsPluginManifest;
37use crate::vcs_plugin_protocol::{
38    CheckReviewParams, CheckReviewResult, CommitParams, CommitResult as PluginCommitResult,
39    DetectParams, DetectResult, ExcludePatternsResult, HandshakeParams, HandshakeResult,
40    MergeReviewParams, MergeReviewResult, OpenReviewParams, OpenReviewResult, PrepareParams,
41    ProtectedTargetsResult, PushParams, PushResult as PluginPushResult, RestoreStateParams,
42    SaveStateResult, SyncUpstreamResult, VcsPluginRequest, VcsPluginResponse, PROTOCOL_VERSION,
43};
44
45/// In-process representation of saved state from an external plugin.
46struct ExternalSavedState {
47    /// Raw JSON blob returned by the plugin's `save_state` response.
48    state_json: serde_json::Value,
49}
50
51/// SourceAdapter that delegates all operations to an external plugin process.
52#[derive(Debug)]
53pub struct ExternalVcsAdapter {
54    /// Plugin command to spawn (first word) and any pre-configured args.
55    command: String,
56    args: Vec<String>,
57    /// Working directory for plugin invocations.
58    work_dir: PathBuf,
59    /// Plugin's self-reported adapter name (from handshake).
60    adapter_name: String,
61    /// Plugin version (from handshake; retained for diagnostics/logging).
62    #[allow(dead_code)]
63    plugin_version: String,
64    /// Per-call process timeout.
65    timeout: Duration,
66    /// Capabilities reported by the plugin at handshake time.
67    capabilities: Vec<String>,
68    /// Static environment variables from the manifest's [staging_env] section (v0.13.17.3).
69    staging_env: std::collections::HashMap<String, String>,
70}
71
72impl ExternalVcsAdapter {
73    /// Create a new adapter and perform the initial handshake.
74    ///
75    /// Returns `SubmitError::ConfigError` if the plugin is not found,
76    /// the handshake fails, or protocol versions are incompatible.
77    pub fn new(
78        manifest: &VcsPluginManifest,
79        work_dir: impl Into<PathBuf>,
80        ta_version: &str,
81    ) -> Result<Self> {
82        let work_dir = work_dir.into();
83        let timeout = Duration::from_secs(manifest.timeout_secs);
84
85        let handshake_params = HandshakeParams {
86            ta_version: ta_version.to_string(),
87            protocol_version: PROTOCOL_VERSION,
88        };
89        let request = VcsPluginRequest {
90            method: "handshake".to_string(),
91            params: serde_json::to_value(&handshake_params).map_err(|e| {
92                SubmitError::ConfigError(format!("Failed to serialize handshake params: {}", e))
93            })?,
94        };
95
96        let response = call_plugin(
97            &manifest.command,
98            &manifest.args,
99            &work_dir,
100            &request,
101            timeout,
102        )?;
103
104        if !response.ok {
105            return Err(SubmitError::ConfigError(format!(
106                "VCS plugin '{}' handshake failed: {}",
107                manifest.name,
108                response.error.as_deref().unwrap_or("unknown error")
109            )));
110        }
111
112        let result: HandshakeResult = serde_json::from_value(response.result).map_err(|e| {
113            SubmitError::ConfigError(format!(
114                "VCS plugin '{}' returned invalid handshake response: {}",
115                manifest.name, e
116            ))
117        })?;
118
119        if result.protocol_version != PROTOCOL_VERSION {
120            return Err(SubmitError::ConfigError(format!(
121                "VCS plugin '{}' uses protocol version {} but TA requires version {}. \
122                 Upgrade the plugin or downgrade TA.",
123                manifest.name, result.protocol_version, PROTOCOL_VERSION
124            )));
125        }
126
127        tracing::info!(
128            plugin = %manifest.name,
129            plugin_version = %result.plugin_version,
130            adapter = %result.adapter_name,
131            "VCS plugin handshake successful"
132        );
133
134        Ok(Self {
135            command: manifest.command.clone(),
136            args: manifest.args.clone(),
137            work_dir,
138            adapter_name: result.adapter_name,
139            plugin_version: result.plugin_version,
140            timeout,
141            capabilities: result.capabilities,
142            staging_env: manifest.staging_env.clone(),
143        })
144    }
145
146    /// Auto-detect using the plugin's `detect` method.
147    pub fn detect_with_plugin(
148        manifest: &VcsPluginManifest,
149        project_root: &Path,
150        ta_version: &str,
151    ) -> bool {
152        let timeout = Duration::from_secs(manifest.timeout_secs);
153        let params = DetectParams {
154            project_root: project_root.display().to_string(),
155        };
156        let request = VcsPluginRequest {
157            method: "detect".to_string(),
158            params: match serde_json::to_value(&params) {
159                Ok(v) => v,
160                Err(_) => return false,
161            },
162        };
163
164        // Perform handshake first (required by protocol).
165        let handshake_req = VcsPluginRequest {
166            method: "handshake".to_string(),
167            params: serde_json::json!({
168                "ta_version": ta_version,
169                "protocol_version": PROTOCOL_VERSION
170            }),
171        };
172        if call_plugin(
173            &manifest.command,
174            &manifest.args,
175            project_root,
176            &handshake_req,
177            timeout,
178        )
179        .map(|r| r.ok)
180        .unwrap_or(false)
181        {
182            // Now call detect
183            match call_plugin(
184                &manifest.command,
185                &manifest.args,
186                project_root,
187                &request,
188                timeout,
189            ) {
190                Ok(resp) if resp.ok => serde_json::from_value::<DetectResult>(resp.result)
191                    .map(|r| r.detected)
192                    .unwrap_or(false),
193                _ => false,
194            }
195        } else {
196            false
197        }
198    }
199
200    /// Call a plugin method and return the parsed result JSON value.
201    fn call<T: serde::de::DeserializeOwned>(
202        &self,
203        method: &str,
204        params: serde_json::Value,
205    ) -> Result<T> {
206        let request = VcsPluginRequest {
207            method: method.to_string(),
208            params,
209        };
210        let response = call_plugin(
211            &self.command,
212            &self.args,
213            &self.work_dir,
214            &request,
215            self.timeout,
216        )?;
217
218        if !response.ok {
219            return Err(SubmitError::VcsError(format!(
220                "VCS plugin '{}' method '{}' failed: {}",
221                self.adapter_name,
222                method,
223                response.error.as_deref().unwrap_or("unknown error")
224            )));
225        }
226
227        serde_json::from_value(response.result).map_err(|e| {
228            SubmitError::VcsError(format!(
229                "VCS plugin '{}' method '{}' returned invalid response: {}",
230                self.adapter_name, method, e
231            ))
232        })
233    }
234
235    /// Whether this plugin declares a given capability.
236    fn has_capability(&self, cap: &str) -> bool {
237        self.capabilities.iter().any(|c| c == cap)
238    }
239}
240
241impl SourceAdapter for ExternalVcsAdapter {
242    fn prepare(&self, goal: &GoalRun, config: &SubmitConfig) -> Result<()> {
243        let params = PrepareParams {
244            goal_id: goal.goal_run_id.to_string(),
245            goal_title: goal.title.clone(),
246            workspace_path: goal.workspace_path.display().to_string(),
247            branch_prefix: config.git.branch_prefix.clone(),
248            co_author: if config.co_author.is_empty() {
249                None
250            } else {
251                Some(config.co_author.clone())
252            },
253        };
254        self.call::<serde_json::Value>("prepare", serde_json::to_value(&params).unwrap())?;
255        Ok(())
256    }
257
258    fn commit(&self, goal: &GoalRun, pr: &DraftPackage, message: &str) -> Result<CommitResult> {
259        let changed_files: Vec<String> = pr
260            .changes
261            .artifacts
262            .iter()
263            .map(|a| {
264                a.resource_uri
265                    .trim_start_matches("fs://workspace/")
266                    .to_string()
267            })
268            .collect();
269
270        let params = CommitParams {
271            goal_id: goal.goal_run_id.to_string(),
272            goal_title: goal.title.clone(),
273            message: message.to_string(),
274            changed_files,
275        };
276
277        let result: PluginCommitResult =
278            self.call("commit", serde_json::to_value(&params).unwrap())?;
279
280        Ok(CommitResult {
281            commit_id: result.commit_id,
282            message: result.message,
283            metadata: result.metadata,
284            ignored_artifacts: vec![],
285        })
286    }
287
288    fn push(&self, goal: &GoalRun) -> Result<PushResult> {
289        let params = PushParams {
290            goal_id: goal.goal_run_id.to_string(),
291        };
292        let result: PluginPushResult = self.call("push", serde_json::to_value(&params).unwrap())?;
293
294        Ok(PushResult {
295            remote_ref: result.remote_ref,
296            message: result.message,
297            metadata: result.metadata,
298        })
299    }
300
301    fn open_review(&self, goal: &GoalRun, pr: &DraftPackage) -> Result<ReviewResult> {
302        let changed_files: Vec<String> = pr
303            .changes
304            .artifacts
305            .iter()
306            .map(|a| {
307                a.resource_uri
308                    .trim_start_matches("fs://workspace/")
309                    .to_string()
310            })
311            .collect();
312
313        let draft_summary = format!("{}\n{}", pr.summary.what_changed, pr.summary.why);
314
315        let params = OpenReviewParams {
316            goal_id: goal.goal_run_id.to_string(),
317            goal_title: goal.title.clone(),
318            draft_summary,
319            changed_files,
320        };
321        let result: OpenReviewResult =
322            self.call("open_review", serde_json::to_value(&params).unwrap())?;
323
324        Ok(ReviewResult {
325            review_url: result.review_url,
326            review_id: result.review_id,
327            message: result.message,
328            metadata: result.metadata,
329        })
330    }
331
332    fn sync_upstream(&self) -> Result<SyncResult> {
333        let result: SyncUpstreamResult = self.call(
334            "sync_upstream",
335            serde_json::Value::Object(Default::default()),
336        )?;
337
338        Ok(SyncResult {
339            updated: result.updated,
340            conflicts: result.conflicts,
341            new_commits: result.new_commits,
342            message: result.message,
343            metadata: result.metadata,
344        })
345    }
346
347    fn name(&self) -> &str {
348        &self.adapter_name
349    }
350
351    fn exclude_patterns(&self) -> Vec<String> {
352        self.call::<ExcludePatternsResult>(
353            "exclude_patterns",
354            serde_json::Value::Object(Default::default()),
355        )
356        .map(|r| r.patterns)
357        .unwrap_or_else(|e| {
358            tracing::warn!(
359                adapter = %self.adapter_name,
360                error = %e,
361                "VCS plugin exclude_patterns failed — using empty list"
362            );
363            vec![]
364        })
365    }
366
367    fn save_state(&self) -> Result<Option<SavedVcsState>> {
368        let result: SaveStateResult =
369            self.call("save_state", serde_json::Value::Object(Default::default()))?;
370
371        if result.state.is_null() {
372            return Ok(None);
373        }
374
375        Ok(Some(SavedVcsState {
376            adapter: self.adapter_name.clone(),
377            data: Box::new(ExternalSavedState {
378                state_json: result.state,
379            }),
380        }))
381    }
382
383    fn restore_state(&self, state: Option<SavedVcsState>) -> Result<()> {
384        let state_json = match state {
385            None => serde_json::Value::Null,
386            Some(s) => {
387                if s.adapter != self.adapter_name {
388                    return Err(SubmitError::InvalidState(format!(
389                        "Cannot restore state from adapter '{}' in ExternalVcsAdapter for '{}'",
390                        s.adapter, self.adapter_name
391                    )));
392                }
393                match s.data.downcast::<ExternalSavedState>() {
394                    Ok(ext) => ext.state_json,
395                    Err(_) => {
396                        return Err(SubmitError::InvalidState(
397                            "State data is not ExternalSavedState".to_string(),
398                        ));
399                    }
400                }
401            }
402        };
403
404        let params = RestoreStateParams { state: state_json };
405        self.call::<serde_json::Value>("restore_state", serde_json::to_value(&params).unwrap())?;
406        Ok(())
407    }
408
409    fn revision_id(&self) -> Result<String> {
410        let result: crate::vcs_plugin_protocol::RevisionIdResult =
411            self.call("revision_id", serde_json::Value::Object(Default::default()))?;
412        Ok(result.revision_id)
413    }
414
415    fn check_review(&self, review_id: &str) -> Result<Option<ReviewStatus>> {
416        let params = CheckReviewParams {
417            review_id: review_id.to_string(),
418        };
419        let result: CheckReviewResult =
420            self.call("check_review", serde_json::to_value(&params).unwrap())?;
421
422        if !result.found {
423            return Ok(None);
424        }
425
426        Ok(Some(ReviewStatus {
427            state: result.state,
428            checks_passing: result.checks_passing,
429        }))
430    }
431
432    fn merge_review(&self, review_id: &str) -> Result<MergeResult> {
433        let params = MergeReviewParams {
434            review_id: review_id.to_string(),
435        };
436        let result: MergeReviewResult =
437            self.call("merge_review", serde_json::to_value(&params).unwrap())?;
438
439        Ok(MergeResult {
440            merged: result.merged,
441            merge_commit: result.merge_commit,
442            message: result.message,
443            metadata: result.metadata,
444        })
445    }
446
447    fn protected_submit_targets(&self) -> Vec<String> {
448        if !self.has_capability("protected_targets") {
449            return vec![];
450        }
451        self.call::<ProtectedTargetsResult>(
452            "protected_targets",
453            serde_json::Value::Object(Default::default()),
454        )
455        .map(|r| r.targets)
456        .unwrap_or_else(|e| {
457            tracing::warn!(
458                adapter = %self.adapter_name,
459                error = %e,
460                "VCS plugin protected_targets failed — returning empty list"
461            );
462            vec![]
463        })
464    }
465
466    fn verify_not_on_protected_target(&self) -> Result<()> {
467        if !self.has_capability("protected_targets") {
468            // Plugin doesn't claim §15 compliance — skip check, log notice.
469            tracing::debug!(
470                adapter = %self.adapter_name,
471                "VCS plugin does not declare 'protected_targets' capability; \
472                 skipping §15 verify_target check"
473            );
474            return Ok(());
475        }
476
477        let response = {
478            let request = VcsPluginRequest {
479                method: "verify_target".to_string(),
480                params: serde_json::Value::Object(Default::default()),
481            };
482            call_plugin(
483                &self.command,
484                &self.args,
485                &self.work_dir,
486                &request,
487                self.timeout,
488            )?
489        };
490
491        if response.ok {
492            Ok(())
493        } else {
494            Err(SubmitError::InvalidState(response.error.unwrap_or_else(
495                || "VCS plugin verify_target returned ok=false".to_string(),
496            )))
497        }
498    }
499
500    fn stage_env(
501        &self,
502        _staging_dir: &Path,
503        _config: &crate::config::VcsAgentConfig,
504    ) -> Result<std::collections::HashMap<String, String>> {
505        // Return static vars from the manifest's [staging_env] section.
506        Ok(self.staging_env.clone())
507    }
508}
509
510// ---------------------------------------------------------------------------
511// Low-level plugin call
512// ---------------------------------------------------------------------------
513
514/// Spawn the plugin, send one JSON request, read one JSON response.
515///
516/// Returns `SubmitError::IoError` / `SubmitError::VcsError` on infrastructure
517/// failures. Never panics.
518fn call_plugin(
519    command: &str,
520    extra_args: &[String],
521    work_dir: &Path,
522    request: &VcsPluginRequest,
523    timeout: Duration,
524) -> Result<VcsPluginResponse> {
525    let request_json = serde_json::to_string(request).map_err(|e| {
526        SubmitError::VcsError(format!(
527            "Failed to serialize VCS plugin request for method '{}': {}",
528            request.method, e
529        ))
530    })?;
531
532    // Split command into program + built-in args.
533    let mut parts = command.split_whitespace();
534    let program = parts.next().ok_or_else(|| {
535        SubmitError::ConfigError(format!(
536            "VCS plugin command is empty for method '{}'",
537            request.method
538        ))
539    })?;
540
541    let mut cmd = Command::new(program);
542    for arg in parts {
543        cmd.arg(arg);
544    }
545    for arg in extra_args {
546        cmd.arg(arg);
547    }
548
549    cmd.current_dir(work_dir)
550        .stdin(Stdio::piped())
551        .stdout(Stdio::piped())
552        .stderr(Stdio::piped());
553
554    // Retry on ETXTBSY (os error 26): on Linux the kernel can return this when a
555    // freshly-written executable has not yet been fully flushed through the page
556    // cache / copy-up layer (common on overlayfs in Nix CI). A short backoff is
557    // sufficient; real plugin binaries never trigger this in production.
558    let mut child = {
559        const ETXTBSY: i32 = 26;
560        let mut last_err: Option<std::io::Error> = None;
561        let mut spawned = None;
562        for delay_ms in [0u64, 20, 80, 200] {
563            if delay_ms > 0 {
564                std::thread::sleep(std::time::Duration::from_millis(delay_ms));
565            }
566            match cmd.spawn() {
567                Ok(c) => {
568                    spawned = Some(c);
569                    break;
570                }
571                Err(e) if e.raw_os_error() == Some(ETXTBSY) => {
572                    last_err = Some(e);
573                }
574                Err(e) => {
575                    return Err(SubmitError::VcsError(format!(
576                        "Failed to spawn VCS plugin '{}' for method '{}': {}. \
577                         Ensure the plugin is installed and on PATH.",
578                        command, request.method, e
579                    )));
580                }
581            }
582        }
583        spawned.ok_or_else(|| {
584            let e = last_err.unwrap();
585            SubmitError::VcsError(format!(
586                "Failed to spawn VCS plugin '{}' for method '{}': {}. \
587                 Ensure the plugin is installed and on PATH.",
588                command, request.method, e
589            ))
590        })?
591    };
592
593    // Write request to stdin.
594    if let Some(mut stdin) = child.stdin.take() {
595        stdin
596            .write_all(request_json.as_bytes())
597            .and_then(|_| stdin.write_all(b"\n"))
598            .map_err(|e| {
599                SubmitError::VcsError(format!(
600                    "Failed to write to VCS plugin '{}' stdin: {}",
601                    command, e
602                ))
603            })?;
604    }
605
606    // Wait with timeout (blocking — VCS operations are called synchronously).
607    // We use a thread with a join timeout since std::process::Child has no
608    // built-in timeout.
609    let timeout_millis = timeout.as_millis() as u64;
610    let output = wait_with_timeout(child, timeout_millis).map_err(|e| {
611        SubmitError::VcsError(format!(
612            "VCS plugin '{}' timed out or failed for method '{}': {}. \
613             Increase timeout_secs in plugin.toml.",
614            command, request.method, e
615        ))
616    })?;
617
618    if !output.status.success() {
619        let stderr = String::from_utf8_lossy(&output.stderr);
620        return Err(SubmitError::VcsError(format!(
621            "VCS plugin '{}' exited with status {} for method '{}'. stderr: {}",
622            command,
623            output.status,
624            request.method,
625            stderr.trim()
626        )));
627    }
628
629    let stdout = String::from_utf8_lossy(&output.stdout);
630    let first_line = stdout.lines().next().unwrap_or("").trim();
631
632    if first_line.is_empty() {
633        return Err(SubmitError::VcsError(format!(
634            "VCS plugin '{}' produced no output for method '{}'. \
635             Plugin must write one JSON line to stdout.",
636            command, request.method
637        )));
638    }
639
640    serde_json::from_str(first_line).map_err(|e| {
641        SubmitError::VcsError(format!(
642            "VCS plugin '{}' produced invalid JSON for method '{}': {}. Got: '{}'",
643            command,
644            request.method,
645            e,
646            if first_line.len() > 200 {
647                &first_line[..200]
648            } else {
649                first_line
650            }
651        ))
652    })
653}
654
655/// Wait for a child process to exit, killing it after `timeout_ms` milliseconds.
656///
657/// Uses an `mpsc` channel to signal the watchdog thread as soon as the child
658/// exits, so `join()` returns immediately rather than blocking for the full
659/// `timeout_ms` on every successful (fast) invocation.
660fn wait_with_timeout(
661    child: std::process::Child,
662    timeout_ms: u64,
663) -> std::result::Result<std::process::Output, String> {
664    use std::sync::mpsc;
665
666    let child_id = child.id();
667    let (tx, rx) = mpsc::channel::<()>();
668
669    // Watchdog thread: waits for the "done" signal or the timeout, then kills.
670    let watchdog = std::thread::spawn(move || {
671        match rx.recv_timeout(Duration::from_millis(timeout_ms)) {
672            Ok(()) => {
673                // Child exited normally — nothing to do.
674            }
675            Err(_) => {
676                // Timeout expired (or sender dropped on early `?` return) — kill the child.
677                #[cfg(unix)]
678                unsafe {
679                    libc::kill(child_id as libc::pid_t, libc::SIGKILL);
680                }
681                #[cfg(not(unix))]
682                {
683                    let _ = child_id;
684                }
685            }
686        }
687    });
688
689    let output = child
690        .wait_with_output()
691        .map_err(|e| format!("wait_with_output failed: {}", e))?;
692
693    // Signal the watchdog that the child has exited — it will wake immediately.
694    let _ = tx.send(());
695    let _ = watchdog.join();
696
697    Ok(output)
698}
699
700// ---------------------------------------------------------------------------
701// Tests
702// ---------------------------------------------------------------------------
703
704#[cfg(all(test, unix))]
705mod tests {
706    use super::*;
707    use std::os::unix::fs::PermissionsExt;
708
709    /// Write a shell-script mock plugin to a temp file and make it executable.
710    ///
711    /// On Linux we write to `/tmp` directly (always tmpfs) rather than using the
712    /// test's `tempdir()` path.  `tempdir()` respects `$TMPDIR` which Nix's devShell
713    /// sets to an overlayfs-backed directory; exec-ing a newly created file there
714    /// races against the kernel's copy-up and returns ETXTBSY (os error 26).
715    /// On macOS and other platforms the provided `dir` is used as normal.
716    fn write_mock_plugin(_dir: &Path, script: &str) -> PathBuf {
717        use std::sync::atomic::{AtomicU32, Ordering};
718        static COUNTER: AtomicU32 = AtomicU32::new(0);
719        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
720        let pid = std::process::id();
721        let name = format!("ta-submit-mock-{}-{}", pid, n);
722        // On Linux use /tmp (tmpfs) to avoid ETXTBSY on overlayfs-backed TMPDIR.
723        #[cfg(target_os = "linux")]
724        let path = std::path::PathBuf::from("/tmp").join(&name);
725        #[cfg(not(target_os = "linux"))]
726        let path = _dir.join(&name);
727        {
728            use std::io::Write;
729            let mut f = std::fs::File::create(&path).unwrap();
730            f.write_all(script.as_bytes()).unwrap();
731            f.sync_all().unwrap();
732        }
733        let mut perms = std::fs::metadata(&path).unwrap().permissions();
734        perms.set_mode(0o755);
735        std::fs::set_permissions(&path, perms).unwrap();
736        // Read back metadata to ensure the chmod is visible before exec.
737        let _ = std::fs::metadata(&path).unwrap();
738        path
739    }
740
741    fn mock_manifest(command: &str, _dir: &Path) -> VcsPluginManifest {
742        VcsPluginManifest {
743            name: "mock".to_string(),
744            version: "0.1.0".to_string(),
745            plugin_type: "vcs".to_string(),
746            command: command.to_string(),
747            args: vec![],
748            capabilities: vec!["commit".to_string(), "protected_targets".to_string()],
749            description: None,
750            timeout_secs: 30,
751            min_daemon_version: None,
752            source_url: None,
753            staging_env: std::collections::HashMap::new(),
754        }
755    }
756
757    #[test]
758    fn call_plugin_with_echo_script() {
759        let dir = tempfile::tempdir().unwrap();
760
761        // A minimal shell script that echoes a valid handshake response.
762        let plugin_path = write_mock_plugin(
763            dir.path(),
764            r#"#!/bin/sh
765read -r line
766echo '{"ok":true,"result":{"plugin_version":"0.1.0","protocol_version":1,"adapter_name":"mock","capabilities":["commit","protected_targets"]}}'
767"#,
768        );
769
770        let manifest = mock_manifest(&plugin_path.display().to_string(), dir.path());
771
772        let adapter = ExternalVcsAdapter::new(&manifest, dir.path(), "0.13.5-alpha").unwrap();
773        assert_eq!(adapter.name(), "mock");
774        assert_eq!(adapter.plugin_version, "0.1.0");
775    }
776
777    #[test]
778    fn handshake_protocol_mismatch_returns_error() {
779        let dir = tempfile::tempdir().unwrap();
780
781        // Plugin claims protocol version 99 — incompatible.
782        let plugin_path = write_mock_plugin(
783            dir.path(),
784            r#"#!/bin/sh
785read -r line
786echo '{"ok":true,"result":{"plugin_version":"0.1.0","protocol_version":99,"adapter_name":"bad","capabilities":[]}}'
787"#,
788        );
789
790        let manifest = mock_manifest(&plugin_path.display().to_string(), dir.path());
791
792        let err = ExternalVcsAdapter::new(&manifest, dir.path(), "0.13.5-alpha").unwrap_err();
793        assert!(
794            err.to_string().contains("protocol version"),
795            "Expected protocol version error, got: {}",
796            err
797        );
798    }
799
800    #[test]
801    fn handshake_error_response_returns_error() {
802        let dir = tempfile::tempdir().unwrap();
803
804        let plugin_path = write_mock_plugin(
805            dir.path(),
806            r#"#!/bin/sh
807read -r line
808echo '{"ok":false,"error":"plugin initialization failed"}'
809"#,
810        );
811
812        let manifest = mock_manifest(&plugin_path.display().to_string(), dir.path());
813
814        let err = ExternalVcsAdapter::new(&manifest, dir.path(), "0.13.5-alpha").unwrap_err();
815        let msg = err.to_string();
816        assert!(
817            msg.contains("handshake failed") || msg.contains("timed out") || msg.contains("error"),
818            "Expected handshake failure, got: {}",
819            msg
820        );
821    }
822
823    #[test]
824    fn missing_command_returns_error() {
825        let dir = tempfile::tempdir().unwrap();
826        let manifest = mock_manifest("ta-submit-nonexistent-binary-xyz", dir.path());
827
828        let err = ExternalVcsAdapter::new(&manifest, dir.path(), "0.13.5-alpha").unwrap_err();
829        assert!(
830            err.to_string().contains("spawn") || err.to_string().contains("No such file"),
831            "Expected spawn error, got: {}",
832            err
833        );
834    }
835
836    #[test]
837    fn plugin_non_zero_exit_returns_error() {
838        let dir = tempfile::tempdir().unwrap();
839
840        let plugin_path = write_mock_plugin(
841            dir.path(),
842            r#"#!/bin/sh
843read -r line
844echo "some error to stderr" >&2
845exit 1
846"#,
847        );
848
849        let manifest = mock_manifest(&plugin_path.display().to_string(), dir.path());
850
851        let err = ExternalVcsAdapter::new(&manifest, dir.path(), "0.13.5-alpha").unwrap_err();
852        assert!(
853            err.to_string().contains("exited with status"),
854            "Got: {}",
855            err
856        );
857    }
858
859    #[test]
860    fn plugin_invalid_json_output_returns_error() {
861        let dir = tempfile::tempdir().unwrap();
862
863        let plugin_path = write_mock_plugin(
864            dir.path(),
865            r#"#!/bin/sh
866read -r line
867echo "this is not json"
868"#,
869        );
870
871        let manifest = mock_manifest(&plugin_path.display().to_string(), dir.path());
872
873        let err = ExternalVcsAdapter::new(&manifest, dir.path(), "0.13.5-alpha").unwrap_err();
874        assert!(err.to_string().contains("invalid JSON"), "Got: {}", err);
875    }
876}