Skip to main content

recursive/tools/
transport.rs

1//! Transport abstraction: decouple tools from direct filesystem/shell access.
2//!
3//! The `ToolTransport` trait lets tools delegate I/O to a pluggable backend.
4//! The default `LocalTransport` calls `tokio::fs` / `tokio::process` directly.
5//! A mock transport can be injected in tests to avoid touching the real disk.
6//!
7//! # SSH Transport
8//!
9//! `SshTransport` executes commands and file operations on a remote host
10//! via the system `ssh` binary. No Rust SSH library needed — delegates to
11//! the installed OpenSSH client.
12//!
13//! Host format: `user@host` or `user@host:port`.
14
15use async_trait::async_trait;
16use std::path::Path;
17use std::path::PathBuf;
18use std::process::Stdio;
19use std::time::Duration;
20use tokio::io::AsyncReadExt;
21use tokio::process::Command;
22
23/// Result of reading a file.
24#[derive(Debug, Clone)]
25pub struct ReadResult {
26    pub bytes: Vec<u8>,
27}
28
29/// Result of listing a directory entry.
30#[derive(Debug, Clone)]
31pub struct DirEntry {
32    pub name: String,
33    pub is_dir: bool,
34}
35
36/// Result of running a shell command.
37#[derive(Debug, Clone)]
38pub struct ExecResult {
39    pub exit_code: Option<i32>,
40    pub stdout: String,
41    pub stderr: String,
42}
43
44/// Abstract transport for filesystem and shell operations.
45///
46/// Tools that need I/O (`ReadFile`, `WriteFile`, `ListDir`, `RunShell`)
47/// call methods on this trait instead of using `tokio::fs` / `tokio::process`
48/// directly. This makes them testable without touching the real filesystem.
49#[async_trait]
50pub trait ToolTransport: Send + Sync + std::fmt::Debug {
51    /// Read the full contents of a file at `path`.
52    async fn read_file(&self, path: &Path) -> std::io::Result<Vec<u8>>;
53
54    /// Write `contents` to a file at `path`, creating parent directories.
55    async fn write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()>;
56
57    /// List entries in a directory at `path`.
58    async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<DirEntry>>;
59
60    /// Create a directory and all parents.
61    async fn create_dir_all(&self, path: &Path) -> std::io::Result<()>;
62
63    /// Execute a shell command in the given working directory with optional
64    /// environment variables, timeout, and max output bytes.
65    async fn exec_shell(
66        &self,
67        command: &str,
68        cwd: &Path,
69        env: &[(String, String)],
70        timeout: Duration,
71        max_output_bytes: usize,
72    ) -> std::io::Result<ExecResult>;
73}
74
75// ---------------------------------------------------------------------------
76// SSH Transport
77// ---------------------------------------------------------------------------
78
79/// SSH transport: executes commands and file operations on a remote host
80/// via the system `ssh` binary.
81///
82/// # Host format
83///
84/// - `user@host` — default SSH port (22)
85/// - `user@host:port` — custom port
86///
87/// # Auth
88///
89/// By default uses `ssh-agent` or `~/.ssh/id_*` keys. Optionally specify
90/// a private key path via `key_path`.
91#[derive(Debug, Clone)]
92pub struct SshTransport {
93    /// SSH connection string: user@host or user@host:port
94    host: String,
95    /// Path to private key (optional, defaults to ssh-agent)
96    key_path: Option<PathBuf>,
97    /// Remote workspace directory
98    remote_workspace: PathBuf,
99    /// SSH connect timeout
100    connect_timeout: Duration,
101    /// SSH command timeout
102    command_timeout: Duration,
103}
104
105impl SshTransport {
106    /// Create a new SSH transport.
107    ///
108    /// `host` is in `user@host` or `user@host:port` format.
109    /// `remote_workspace` is the absolute path to the workspace on the remote machine.
110    pub fn new(host: impl Into<String>, remote_workspace: impl Into<PathBuf>) -> Self {
111        Self {
112            host: host.into(),
113            key_path: None,
114            remote_workspace: remote_workspace.into(),
115            connect_timeout: Duration::from_secs(10),
116            command_timeout: Duration::from_secs(300),
117        }
118    }
119
120    /// Set the path to an SSH private key.
121    pub fn with_key(mut self, key_path: impl Into<PathBuf>) -> Self {
122        self.key_path = Some(key_path.into());
123        self
124    }
125
126    /// Set the SSH connect timeout.
127    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
128        self.connect_timeout = timeout;
129        self
130    }
131
132    /// Set the SSH command timeout.
133    pub fn with_command_timeout(mut self, timeout: Duration) -> Self {
134        self.command_timeout = timeout;
135        self
136    }
137
138    /// Return the host string.
139    pub fn host(&self) -> &str {
140        &self.host
141    }
142
143    /// Return the key path, if set.
144    pub fn key_path(&self) -> Option<&Path> {
145        self.key_path.as_deref()
146    }
147
148    /// Return the remote workspace path.
149    pub fn remote_workspace(&self) -> &Path {
150        &self.remote_workspace
151    }
152
153    /// Build a `tokio::process::Command` for an SSH invocation.
154    fn build_ssh_command(&self, remote_command: &str) -> Command {
155        let mut cmd = Command::new("ssh");
156
157        // Non-interactive options
158        cmd.arg("-o").arg("BatchMode=yes");
159        cmd.arg("-o").arg("StrictHostKeyChecking=no");
160        cmd.arg("-o").arg("UserKnownHostsFile=/dev/null");
161        cmd.arg("-o")
162            .arg(format!("ConnectTimeout={}", self.connect_timeout.as_secs()));
163
164        // Optional identity file
165        if let Some(ref key) = self.key_path {
166            cmd.arg("-i").arg(key);
167        }
168
169        // Parse host:port format
170        let (host, port) = parse_host(&self.host);
171        if let Some(p) = port {
172            cmd.arg("-p").arg(p.to_string());
173        }
174
175        cmd.arg(&host);
176        cmd.arg(remote_command);
177
178        cmd.stdout(Stdio::piped());
179        cmd.stderr(Stdio::piped());
180
181        cmd
182    }
183
184    /// Execute a command via SSH and capture stdout/stderr.
185    async fn ssh_exec(&self, command: &str) -> std::io::Result<ExecResult> {
186        let mut child = self.build_ssh_command(command).spawn()?;
187
188        let mut stdout = child.stdout.take().expect("stdout piped");
189        let mut stderr = child.stderr.take().expect("stderr piped");
190
191        let max: usize = 128 * 1024;
192        let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
193        let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
194
195        let wait = child.wait();
196        let status = match tokio::time::timeout(self.command_timeout, wait).await {
197            Ok(s) => s?,
198            Err(_) => {
199                return Err(std::io::Error::new(
200                    std::io::ErrorKind::TimedOut,
201                    format!("SSH command timed out after {:?}", self.command_timeout),
202                ));
203            }
204        };
205
206        let out = stdout_task.await.unwrap_or_default();
207        let err = stderr_task.await.unwrap_or_default();
208        let code = status.code();
209
210        Ok(ExecResult {
211            exit_code: code,
212            stdout: out,
213            stderr: err,
214        })
215    }
216
217    /// Write content to a remote file by piping base64 over SSH.
218    async fn ssh_write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
219        // First ensure the parent directory exists
220        if let Some(parent) = path.parent() {
221            let mkdir_cmd = format!(
222                "mkdir -p {}",
223                shell_escape(parent.to_string_lossy().as_ref())
224            );
225            let result = self.ssh_exec(&mkdir_cmd).await?;
226            if result.exit_code != Some(0) {
227                return Err(std::io::Error::other(format!(
228                    "mkdir failed on remote: {}",
229                    result.stderr
230                )));
231            }
232        }
233
234        // Write file content via base64 to avoid escaping issues
235        let b64 = base64_encode(contents);
236        let write_cmd = format!(
237            "echo '{}' | base64 -d > {}",
238            b64,
239            shell_escape(path.to_string_lossy().as_ref())
240        );
241        let result = self.ssh_exec(&write_cmd).await?;
242        if result.exit_code != Some(0) {
243            return Err(std::io::Error::other(format!(
244                "write failed on remote: {}",
245                result.stderr
246            )));
247        }
248        Ok(())
249    }
250}
251
252#[async_trait]
253impl ToolTransport for SshTransport {
254    async fn read_file(&self, path: &Path) -> std::io::Result<Vec<u8>> {
255        let cmd = format!("cat {}", shell_escape(path.to_string_lossy().as_ref()));
256        let result = self.ssh_exec(&cmd).await?;
257        if result.exit_code != Some(0) {
258            return Err(std::io::Error::new(
259                std::io::ErrorKind::NotFound,
260                format!(
261                    "remote file not found: {}: {}",
262                    path.display(),
263                    result.stderr
264                ),
265            ));
266        }
267        Ok(result.stdout.into_bytes())
268    }
269
270    async fn write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
271        self.ssh_write_file(path, contents).await
272    }
273
274    async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<DirEntry>> {
275        let cmd = format!("ls -1a {}", shell_escape(path.to_string_lossy().as_ref()));
276        let result = self.ssh_exec(&cmd).await?;
277        if result.exit_code != Some(0) {
278            return Err(std::io::Error::new(
279                std::io::ErrorKind::NotFound,
280                format!(
281                    "remote dir not found: {}: {}",
282                    path.display(),
283                    result.stderr
284                ),
285            ));
286        }
287
288        let mut entries = Vec::new();
289        for name in result.stdout.lines() {
290            let name = name.trim();
291            if name.is_empty() || name == "." || name == ".." {
292                continue;
293            }
294            // Check if it's a directory via a separate SSH call
295            let test_cmd = format!(
296                "test -d {} && echo dir || echo file",
297                shell_escape(&format!("{}/{}", path.to_string_lossy(), name))
298            );
299            let is_dir = self
300                .ssh_exec(&test_cmd)
301                .await
302                .ok()
303                .map(|r| r.stdout.trim() == "dir")
304                .unwrap_or(false);
305            entries.push(DirEntry {
306                name: name.to_string(),
307                is_dir,
308            });
309        }
310
311        Ok(entries)
312    }
313
314    async fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
315        let cmd = format!("mkdir -p {}", shell_escape(path.to_string_lossy().as_ref()));
316        let result = self.ssh_exec(&cmd).await?;
317        if result.exit_code != Some(0) {
318            return Err(std::io::Error::other(format!(
319                "mkdir failed on remote: {}",
320                result.stderr
321            )));
322        }
323        Ok(())
324    }
325
326    async fn exec_shell(
327        &self,
328        command: &str,
329        cwd: &Path,
330        env: &[(String, String)],
331        timeout: Duration,
332        max_output_bytes: usize,
333    ) -> std::io::Result<ExecResult> {
334        // Build the remote command: cd to workspace, set env vars, run command
335        let mut env_prefix = String::new();
336        for (key, val) in env {
337            env_prefix.push_str(&format!("{}={} ", key, shell_escape(val)));
338        }
339
340        let remote_cmd = format!(
341            "cd {} && {} {}",
342            shell_escape(cwd.to_string_lossy().as_ref()),
343            env_prefix,
344            command
345        );
346
347        let mut child = self.build_ssh_command(&remote_cmd).spawn()?;
348
349        let mut stdout = child.stdout.take().expect("stdout piped");
350        let mut stderr = child.stderr.take().expect("stderr piped");
351
352        let max = max_output_bytes;
353        let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
354        let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
355
356        let wait = child.wait();
357        let status = match tokio::time::timeout(timeout, wait).await {
358            Ok(s) => s?,
359            Err(_) => {
360                return Err(std::io::Error::new(
361                    std::io::ErrorKind::TimedOut,
362                    format!("SSH command timed out after {:?}", timeout),
363                ));
364            }
365        };
366
367        let out = stdout_task.await.unwrap_or_default();
368        let err = stderr_task.await.unwrap_or_default();
369        let code = status.code();
370
371        Ok(ExecResult {
372            exit_code: code,
373            stdout: out,
374            stderr: err,
375        })
376    }
377}
378
379// ---------------------------------------------------------------------------
380// Local Transport
381// ---------------------------------------------------------------------------
382
383/// The default transport that performs real I/O via `tokio::fs` and
384/// `tokio::process`.
385#[derive(Debug, Clone, Default)]
386pub struct LocalTransport;
387
388#[async_trait]
389impl ToolTransport for LocalTransport {
390    async fn read_file(&self, path: &Path) -> std::io::Result<Vec<u8>> {
391        tokio::fs::read(path).await
392    }
393
394    async fn write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
395        if let Some(parent) = path.parent() {
396            self.create_dir_all(parent).await?;
397        }
398        tokio::fs::write(path, contents).await
399    }
400
401    async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<DirEntry>> {
402        let mut read_dir = tokio::fs::read_dir(path).await?;
403        let mut entries = Vec::new();
404        while let Some(entry) = read_dir.next_entry().await? {
405            let name = entry.file_name().to_string_lossy().to_string();
406            let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
407            entries.push(DirEntry { name, is_dir });
408        }
409        Ok(entries)
410    }
411
412    async fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
413        tokio::fs::create_dir_all(path).await
414    }
415
416    async fn exec_shell(
417        &self,
418        command: &str,
419        cwd: &Path,
420        env: &[(String, String)],
421        timeout: Duration,
422        max_output_bytes: usize,
423    ) -> std::io::Result<ExecResult> {
424        let mut cmd = Command::new("/bin/sh");
425        cmd.arg("-c").arg(command);
426        cmd.current_dir(cwd);
427        cmd.stdout(Stdio::piped());
428        cmd.stderr(Stdio::piped());
429
430        for (key, val) in env {
431            cmd.env(key, val);
432        }
433
434        let mut child = cmd.spawn()?;
435
436        let mut stdout = child.stdout.take().expect("stdout piped");
437        let mut stderr = child.stderr.take().expect("stderr piped");
438
439        let max = max_output_bytes;
440        let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
441        let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
442
443        let wait = child.wait();
444        let status = match tokio::time::timeout(timeout, wait).await {
445            Ok(s) => s?,
446            Err(_) => {
447                return Err(std::io::Error::new(
448                    std::io::ErrorKind::TimedOut,
449                    format!("command timed out after {:?}", timeout),
450                ));
451            }
452        };
453
454        let out = stdout_task.await.unwrap_or_default();
455        let err = stderr_task.await.unwrap_or_default();
456        let code = status.code();
457
458        Ok(ExecResult {
459            exit_code: code,
460            stdout: out,
461            stderr: err,
462        })
463    }
464}
465
466// ---------------------------------------------------------------------------
467// Helpers
468// ---------------------------------------------------------------------------
469
470async fn read_capped<R: AsyncReadExt + Unpin>(reader: &mut R, max: usize) -> String {
471    let mut buf = Vec::with_capacity(8 * 1024);
472    let mut tmp = [0u8; 8 * 1024];
473    loop {
474        match reader.read(&mut tmp).await {
475            Ok(0) => break,
476            Ok(n) => {
477                if buf.len() + n > max {
478                    let take = max.saturating_sub(buf.len());
479                    buf.extend_from_slice(&tmp[..take]);
480                    buf.extend_from_slice(b"\n... [output truncated]");
481                    let _ = tokio::io::copy(reader, &mut tokio::io::sink()).await;
482                    break;
483                }
484                buf.extend_from_slice(&tmp[..n]);
485            }
486            Err(_) => break,
487        }
488    }
489    String::from_utf8_lossy(&buf).into_owned()
490}
491
492/// Parse a host string of the form `user@host` or `user@host:port`.
493/// Returns `(host_string, optional_port)`.
494fn parse_host(host: &str) -> (String, Option<u16>) {
495    // Split off port if present (last colon after @)
496    if let Some(at_pos) = host.rfind('@') {
497        let after_at = &host[at_pos + 1..];
498        if let Some(colon_pos) = after_at.rfind(':') {
499            let host_part = format!("{}@{}", &host[..at_pos], &after_at[..colon_pos]);
500            let port: u16 = after_at[colon_pos + 1..].parse().unwrap_or(22);
501            return (host_part, Some(port));
502        }
503    } else {
504        // No @ — just host or host:port
505        if let Some(colon_pos) = host.rfind(':') {
506            let host_part = host[..colon_pos].to_string();
507            let port: u16 = host[colon_pos + 1..].parse().unwrap_or(22);
508            return (host_part, Some(port));
509        }
510    }
511    (host.to_string(), None)
512}
513
514/// Shell-escape a string for safe use in SSH commands.
515/// Wraps in single quotes and escapes any single quotes inside.
516fn shell_escape(s: &str) -> String {
517    let escaped = s.replace('\'', "'\\''");
518    format!("'{}'", escaped)
519}
520
521/// Base64-encode bytes (simple implementation without external crate).
522fn base64_encode(bytes: &[u8]) -> String {
523    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
524    let mut result = String::new();
525    for chunk in bytes.chunks(3) {
526        let b0 = chunk[0] as u32;
527        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
528        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
529        let triple = (b0 << 16) | (b1 << 8) | b2;
530
531        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
532        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
533
534        if chunk.len() > 1 {
535            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
536        } else {
537            result.push('=');
538        }
539
540        if chunk.len() > 2 {
541            result.push(CHARS[(triple & 0x3F) as usize] as char);
542        } else {
543            result.push('=');
544        }
545    }
546    result
547}
548
549// ---------------------------------------------------------------------------
550// Tests
551// ---------------------------------------------------------------------------
552
553#[cfg(test)]
554#[cfg(not(target_os = "windows"))]
555mod tests {
556    use super::*;
557    use tempfile::TempDir;
558
559    // --- Local transport tests (unchanged) ---
560
561    #[tokio::test]
562    async fn local_transport_read_write_roundtrip() {
563        let t = LocalTransport;
564        let tmp = TempDir::new().unwrap();
565        let path = tmp.path().join("hello.txt");
566
567        t.write_file(&path, b"world").await.unwrap();
568        let data = t.read_file(&path).await.unwrap();
569        assert_eq!(data, b"world");
570    }
571
572    #[tokio::test]
573    async fn local_transport_list_dir() {
574        let t = LocalTransport;
575        let tmp = TempDir::new().unwrap();
576        std::fs::write(tmp.path().join("a.txt"), "x").unwrap();
577        std::fs::create_dir(tmp.path().join("sub")).unwrap();
578
579        let entries = t.list_dir(tmp.path()).await.unwrap();
580        let mut names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
581        names.sort();
582        assert_eq!(names, vec!["a.txt", "sub"]);
583        assert!(!entries.iter().any(|e| e.name == "a.txt" && e.is_dir));
584        assert!(entries.iter().any(|e| e.name == "sub" && e.is_dir));
585    }
586
587    #[tokio::test]
588    async fn local_transport_exec_shell() {
589        let t = LocalTransport;
590        let tmp = TempDir::new().unwrap();
591        let result = t
592            .exec_shell(
593                "echo hello",
594                tmp.path(),
595                &[],
596                Duration::from_secs(5),
597                128 * 1024,
598            )
599            .await
600            .unwrap();
601        assert_eq!(result.exit_code, Some(0));
602        assert!(result.stdout.contains("hello"));
603    }
604
605    #[tokio::test]
606    async fn local_transport_exec_shell_with_env() {
607        let t = LocalTransport;
608        let tmp = TempDir::new().unwrap();
609        let result = t
610            .exec_shell(
611                "echo $MY_VAR",
612                tmp.path(),
613                &[("MY_VAR".into(), "test_value".into())],
614                Duration::from_secs(5),
615                128 * 1024,
616            )
617            .await
618            .unwrap();
619        assert_eq!(result.exit_code, Some(0));
620        assert!(result.stdout.contains("test_value"));
621    }
622
623    #[tokio::test]
624    async fn local_transport_exec_shell_timeout() {
625        let t = LocalTransport;
626        let tmp = TempDir::new().unwrap();
627        let result = t
628            .exec_shell(
629                "sleep 10",
630                tmp.path(),
631                &[],
632                Duration::from_millis(100),
633                128 * 1024,
634            )
635            .await;
636        assert!(result.is_err());
637        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::TimedOut);
638    }
639
640    #[tokio::test]
641    async fn local_transport_create_dir_all() {
642        let t = LocalTransport;
643        let tmp = TempDir::new().unwrap();
644        let path = tmp.path().join("a").join("b").join("c");
645        t.create_dir_all(&path).await.unwrap();
646        assert!(path.exists());
647        assert!(path.is_dir());
648    }
649
650    // --- SSH transport tests (no actual SSH required) ---
651
652    #[test]
653    fn ssh_transport_constructor() {
654        let t = SshTransport::new("user@host", "/remote/workspace");
655        assert_eq!(t.host(), "user@host");
656        assert_eq!(t.remote_workspace(), Path::new("/remote/workspace"));
657        assert!(t.key_path().is_none());
658    }
659
660    #[test]
661    fn ssh_transport_with_key() {
662        let t = SshTransport::new("user@host", "/remote/workspace").with_key("/path/to/key");
663        assert_eq!(t.key_path(), Some(Path::new("/path/to/key")));
664    }
665
666    #[test]
667    fn ssh_transport_with_timeouts() {
668        let t = SshTransport::new("user@host", "/remote/workspace")
669            .with_connect_timeout(Duration::from_secs(30))
670            .with_command_timeout(Duration::from_secs(600));
671        // We can't inspect private fields directly, but we can verify
672        // the builder pattern compiles and returns the right type.
673        assert_eq!(t.host(), "user@host");
674    }
675
676    #[test]
677    fn ssh_build_command_basic() {
678        let t = SshTransport::new("user@host", "/remote/workspace");
679        let cmd = t.build_ssh_command("ls -la");
680        let args: Vec<String> = cmd
681            .as_std()
682            .get_args()
683            .map(|s| s.to_string_lossy().to_string())
684            .collect();
685
686        // Should include options, host, and command
687        assert!(args.contains(&"user@host".to_string()));
688        assert!(args.contains(&"ls -la".to_string()));
689        // Should have BatchMode=yes
690        assert!(args.contains(&"-o".to_string()));
691        assert!(args.contains(&"BatchMode=yes".to_string()));
692        // Should have StrictHostKeyChecking=no
693        assert!(args.contains(&"StrictHostKeyChecking=no".to_string()));
694    }
695
696    #[test]
697    fn ssh_build_command_with_key() {
698        let t =
699            SshTransport::new("user@host", "/remote/workspace").with_key("/home/user/.ssh/id_rsa");
700        let cmd = t.build_ssh_command("echo test");
701        let args: Vec<String> = cmd
702            .as_std()
703            .get_args()
704            .map(|s| s.to_string_lossy().to_string())
705            .collect();
706
707        assert!(args.contains(&"-i".to_string()));
708        assert!(args.contains(&"/home/user/.ssh/id_rsa".to_string()));
709    }
710
711    #[test]
712    fn ssh_build_command_with_port() {
713        let t = SshTransport::new("user@host:2222", "/remote/workspace");
714        let cmd = t.build_ssh_command("whoami");
715        let args: Vec<String> = cmd
716            .as_std()
717            .get_args()
718            .map(|s| s.to_string_lossy().to_string())
719            .collect();
720
721        assert!(args.contains(&"-p".to_string()));
722        assert!(args.contains(&"2222".to_string()));
723        assert!(args.contains(&"user@host".to_string()));
724    }
725
726    #[test]
727    fn ssh_build_command_without_user() {
728        // Host without @ — just a hostname
729        let t = SshTransport::new("remote-server:2222", "/remote/workspace");
730        let cmd = t.build_ssh_command("whoami");
731        let args: Vec<String> = cmd
732            .as_std()
733            .get_args()
734            .map(|s| s.to_string_lossy().to_string())
735            .collect();
736
737        assert!(args.contains(&"-p".to_string()));
738        assert!(args.contains(&"2222".to_string()));
739        assert!(args.contains(&"remote-server".to_string()));
740    }
741
742    #[test]
743    fn parse_host_user_at_host() {
744        let (host, port) = parse_host("user@host");
745        assert_eq!(host, "user@host");
746        assert_eq!(port, None);
747    }
748
749    #[test]
750    fn parse_host_user_at_host_port() {
751        let (host, port) = parse_host("user@host:2222");
752        assert_eq!(host, "user@host");
753        assert_eq!(port, Some(2222));
754    }
755
756    #[test]
757    fn parse_host_just_host() {
758        let (host, port) = parse_host("remote-server");
759        assert_eq!(host, "remote-server");
760        assert_eq!(port, None);
761    }
762
763    #[test]
764    fn parse_host_host_with_port_no_user() {
765        let (host, port) = parse_host("remote-server:2222");
766        assert_eq!(host, "remote-server");
767        assert_eq!(port, Some(2222));
768    }
769
770    #[test]
771    fn parse_host_invalid_port_defaults_to_none() {
772        // Invalid port number — should return None for port
773        let (host, port) = parse_host("user@host:notanumber");
774        // Invalid port defaults to 22
775        assert_eq!(host, "user@host");
776        assert_eq!(port, Some(22));
777    }
778
779    #[test]
780    fn shell_escape_simple() {
781        assert_eq!(shell_escape("hello"), "'hello'");
782    }
783
784    #[test]
785    fn shell_escape_with_single_quote() {
786        assert_eq!(shell_escape("it's"), "'it'\\''s'");
787    }
788
789    #[test]
790    fn shell_escape_with_spaces() {
791        assert_eq!(
792            shell_escape("/home/user/my project"),
793            "'/home/user/my project'"
794        );
795    }
796
797    #[test]
798    fn shell_escape_empty() {
799        assert_eq!(shell_escape(""), "''");
800    }
801
802    #[test]
803    fn base64_encode_empty() {
804        assert_eq!(base64_encode(b""), "");
805    }
806
807    #[test]
808    fn base64_encode_hello() {
809        // "hello" in base64 is "aGVsbG8="
810        assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
811    }
812
813    #[test]
814    fn base64_encode_three_bytes() {
815        // "abc" in base64 is "YWJj"
816        assert_eq!(base64_encode(b"abc"), "YWJj");
817    }
818
819    #[test]
820    fn base64_encode_binary() {
821        let bytes = vec![0x00, 0xFF, 0xFE, 0x7F];
822        let encoded = base64_encode(&bytes);
823        assert!(!encoded.is_empty());
824        // Decode check: should be valid base64
825        assert!(encoded.len() % 4 == 0);
826    }
827
828    #[test]
829    fn ssh_transport_is_send_sync() {
830        // Compile-time check: SshTransport must implement Send + Sync
831        fn assert_send_sync<T: Send + Sync>() {}
832        assert_send_sync::<SshTransport>();
833    }
834
835    #[test]
836    fn ssh_transport_debug() {
837        let t = SshTransport::new("user@host", "/remote/workspace");
838        let debug = format!("{:?}", t);
839        assert!(debug.contains("SshTransport"));
840        assert!(debug.contains("user@host"));
841    }
842
843    #[test]
844    fn ssh_transport_clone() {
845        let t = SshTransport::new("user@host", "/remote/workspace").with_key("/path/to/key");
846        let t2 = t.clone();
847        assert_eq!(t.host(), t2.host());
848        assert_eq!(t.key_path(), t2.key_path());
849        assert_eq!(t.remote_workspace(), t2.remote_workspace());
850    }
851
852    #[test]
853    fn ssh_transport_implements_tool_transport() {
854        // Compile-time check: SshTransport must implement ToolTransport
855        fn assert_tool_transport<T: ToolTransport>() {}
856        assert_tool_transport::<SshTransport>();
857    }
858
859    #[test]
860    fn local_transport_implements_tool_transport() {
861        fn assert_tool_transport<T: ToolTransport>() {}
862        assert_tool_transport::<LocalTransport>();
863    }
864
865    /// Test that the SSH command construction includes ConnectTimeout.
866    #[test]
867    fn ssh_build_command_connect_timeout() {
868        let t = SshTransport::new("user@host", "/remote/workspace")
869            .with_connect_timeout(Duration::from_secs(15));
870        let cmd = t.build_ssh_command("echo test");
871        let args: Vec<String> = cmd
872            .as_std()
873            .get_args()
874            .map(|s| s.to_string_lossy().to_string())
875            .collect();
876
877        assert!(args.contains(&"ConnectTimeout=15".to_string()));
878    }
879
880    /// Test that the SSH command construction includes UserKnownHostsFile=/dev/null.
881    #[test]
882    fn ssh_build_command_known_hosts() {
883        let t = SshTransport::new("user@host", "/remote/workspace");
884        let cmd = t.build_ssh_command("echo test");
885        let args: Vec<String> = cmd
886            .as_std()
887            .get_args()
888            .map(|s| s.to_string_lossy().to_string())
889            .collect();
890
891        assert!(args.contains(&"UserKnownHostsFile=/dev/null".to_string()));
892    }
893}