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)]
554mod tests {
555    use super::*;
556    use tempfile::TempDir;
557
558    // --- Local transport tests (unchanged) ---
559
560    #[tokio::test]
561    async fn local_transport_read_write_roundtrip() {
562        let t = LocalTransport;
563        let tmp = TempDir::new().unwrap();
564        let path = tmp.path().join("hello.txt");
565
566        t.write_file(&path, b"world").await.unwrap();
567        let data = t.read_file(&path).await.unwrap();
568        assert_eq!(data, b"world");
569    }
570
571    #[tokio::test]
572    async fn local_transport_list_dir() {
573        let t = LocalTransport;
574        let tmp = TempDir::new().unwrap();
575        std::fs::write(tmp.path().join("a.txt"), "x").unwrap();
576        std::fs::create_dir(tmp.path().join("sub")).unwrap();
577
578        let entries = t.list_dir(tmp.path()).await.unwrap();
579        let mut names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
580        names.sort();
581        assert_eq!(names, vec!["a.txt", "sub"]);
582        assert!(!entries.iter().any(|e| e.name == "a.txt" && e.is_dir));
583        assert!(entries.iter().any(|e| e.name == "sub" && e.is_dir));
584    }
585
586    #[tokio::test]
587    async fn local_transport_exec_shell() {
588        let t = LocalTransport;
589        let tmp = TempDir::new().unwrap();
590        let result = t
591            .exec_shell(
592                "echo hello",
593                tmp.path(),
594                &[],
595                Duration::from_secs(5),
596                128 * 1024,
597            )
598            .await
599            .unwrap();
600        assert_eq!(result.exit_code, Some(0));
601        assert!(result.stdout.contains("hello"));
602    }
603
604    #[tokio::test]
605    async fn local_transport_exec_shell_with_env() {
606        let t = LocalTransport;
607        let tmp = TempDir::new().unwrap();
608        let result = t
609            .exec_shell(
610                "echo $MY_VAR",
611                tmp.path(),
612                &[("MY_VAR".into(), "test_value".into())],
613                Duration::from_secs(5),
614                128 * 1024,
615            )
616            .await
617            .unwrap();
618        assert_eq!(result.exit_code, Some(0));
619        assert!(result.stdout.contains("test_value"));
620    }
621
622    #[tokio::test]
623    async fn local_transport_exec_shell_timeout() {
624        let t = LocalTransport;
625        let tmp = TempDir::new().unwrap();
626        let result = t
627            .exec_shell(
628                "sleep 10",
629                tmp.path(),
630                &[],
631                Duration::from_millis(100),
632                128 * 1024,
633            )
634            .await;
635        assert!(result.is_err());
636        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::TimedOut);
637    }
638
639    #[tokio::test]
640    async fn local_transport_create_dir_all() {
641        let t = LocalTransport;
642        let tmp = TempDir::new().unwrap();
643        let path = tmp.path().join("a").join("b").join("c");
644        t.create_dir_all(&path).await.unwrap();
645        assert!(path.exists());
646        assert!(path.is_dir());
647    }
648
649    // --- SSH transport tests (no actual SSH required) ---
650
651    #[test]
652    fn ssh_transport_constructor() {
653        let t = SshTransport::new("user@host", "/remote/workspace");
654        assert_eq!(t.host(), "user@host");
655        assert_eq!(t.remote_workspace(), Path::new("/remote/workspace"));
656        assert!(t.key_path().is_none());
657    }
658
659    #[test]
660    fn ssh_transport_with_key() {
661        let t = SshTransport::new("user@host", "/remote/workspace").with_key("/path/to/key");
662        assert_eq!(t.key_path(), Some(Path::new("/path/to/key")));
663    }
664
665    #[test]
666    fn ssh_transport_with_timeouts() {
667        let t = SshTransport::new("user@host", "/remote/workspace")
668            .with_connect_timeout(Duration::from_secs(30))
669            .with_command_timeout(Duration::from_secs(600));
670        // We can't inspect private fields directly, but we can verify
671        // the builder pattern compiles and returns the right type.
672        assert_eq!(t.host(), "user@host");
673    }
674
675    #[test]
676    fn ssh_build_command_basic() {
677        let t = SshTransport::new("user@host", "/remote/workspace");
678        let cmd = t.build_ssh_command("ls -la");
679        let args: Vec<String> = cmd
680            .as_std()
681            .get_args()
682            .map(|s| s.to_string_lossy().to_string())
683            .collect();
684
685        // Should include options, host, and command
686        assert!(args.contains(&"user@host".to_string()));
687        assert!(args.contains(&"ls -la".to_string()));
688        // Should have BatchMode=yes
689        assert!(args.contains(&"-o".to_string()));
690        assert!(args.contains(&"BatchMode=yes".to_string()));
691        // Should have StrictHostKeyChecking=no
692        assert!(args.contains(&"StrictHostKeyChecking=no".to_string()));
693    }
694
695    #[test]
696    fn ssh_build_command_with_key() {
697        let t =
698            SshTransport::new("user@host", "/remote/workspace").with_key("/home/user/.ssh/id_rsa");
699        let cmd = t.build_ssh_command("echo test");
700        let args: Vec<String> = cmd
701            .as_std()
702            .get_args()
703            .map(|s| s.to_string_lossy().to_string())
704            .collect();
705
706        assert!(args.contains(&"-i".to_string()));
707        assert!(args.contains(&"/home/user/.ssh/id_rsa".to_string()));
708    }
709
710    #[test]
711    fn ssh_build_command_with_port() {
712        let t = SshTransport::new("user@host:2222", "/remote/workspace");
713        let cmd = t.build_ssh_command("whoami");
714        let args: Vec<String> = cmd
715            .as_std()
716            .get_args()
717            .map(|s| s.to_string_lossy().to_string())
718            .collect();
719
720        assert!(args.contains(&"-p".to_string()));
721        assert!(args.contains(&"2222".to_string()));
722        assert!(args.contains(&"user@host".to_string()));
723    }
724
725    #[test]
726    fn ssh_build_command_without_user() {
727        // Host without @ — just a hostname
728        let t = SshTransport::new("remote-server:2222", "/remote/workspace");
729        let cmd = t.build_ssh_command("whoami");
730        let args: Vec<String> = cmd
731            .as_std()
732            .get_args()
733            .map(|s| s.to_string_lossy().to_string())
734            .collect();
735
736        assert!(args.contains(&"-p".to_string()));
737        assert!(args.contains(&"2222".to_string()));
738        assert!(args.contains(&"remote-server".to_string()));
739    }
740
741    #[test]
742    fn parse_host_user_at_host() {
743        let (host, port) = parse_host("user@host");
744        assert_eq!(host, "user@host");
745        assert_eq!(port, None);
746    }
747
748    #[test]
749    fn parse_host_user_at_host_port() {
750        let (host, port) = parse_host("user@host:2222");
751        assert_eq!(host, "user@host");
752        assert_eq!(port, Some(2222));
753    }
754
755    #[test]
756    fn parse_host_just_host() {
757        let (host, port) = parse_host("remote-server");
758        assert_eq!(host, "remote-server");
759        assert_eq!(port, None);
760    }
761
762    #[test]
763    fn parse_host_host_with_port_no_user() {
764        let (host, port) = parse_host("remote-server:2222");
765        assert_eq!(host, "remote-server");
766        assert_eq!(port, Some(2222));
767    }
768
769    #[test]
770    fn parse_host_invalid_port_defaults_to_none() {
771        // Invalid port number — should return None for port
772        let (host, port) = parse_host("user@host:notanumber");
773        // Invalid port defaults to 22
774        assert_eq!(host, "user@host");
775        assert_eq!(port, Some(22));
776    }
777
778    #[test]
779    fn shell_escape_simple() {
780        assert_eq!(shell_escape("hello"), "'hello'");
781    }
782
783    #[test]
784    fn shell_escape_with_single_quote() {
785        assert_eq!(shell_escape("it's"), "'it'\\''s'");
786    }
787
788    #[test]
789    fn shell_escape_with_spaces() {
790        assert_eq!(
791            shell_escape("/home/user/my project"),
792            "'/home/user/my project'"
793        );
794    }
795
796    #[test]
797    fn shell_escape_empty() {
798        assert_eq!(shell_escape(""), "''");
799    }
800
801    #[test]
802    fn base64_encode_empty() {
803        assert_eq!(base64_encode(b""), "");
804    }
805
806    #[test]
807    fn base64_encode_hello() {
808        // "hello" in base64 is "aGVsbG8="
809        assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
810    }
811
812    #[test]
813    fn base64_encode_three_bytes() {
814        // "abc" in base64 is "YWJj"
815        assert_eq!(base64_encode(b"abc"), "YWJj");
816    }
817
818    #[test]
819    fn base64_encode_binary() {
820        let bytes = vec![0x00, 0xFF, 0xFE, 0x7F];
821        let encoded = base64_encode(&bytes);
822        assert!(!encoded.is_empty());
823        // Decode check: should be valid base64
824        assert!(encoded.len() % 4 == 0);
825    }
826
827    #[test]
828    fn ssh_transport_is_send_sync() {
829        // Compile-time check: SshTransport must implement Send + Sync
830        fn assert_send_sync<T: Send + Sync>() {}
831        assert_send_sync::<SshTransport>();
832    }
833
834    #[test]
835    fn ssh_transport_debug() {
836        let t = SshTransport::new("user@host", "/remote/workspace");
837        let debug = format!("{:?}", t);
838        assert!(debug.contains("SshTransport"));
839        assert!(debug.contains("user@host"));
840    }
841
842    #[test]
843    fn ssh_transport_clone() {
844        let t = SshTransport::new("user@host", "/remote/workspace").with_key("/path/to/key");
845        let t2 = t.clone();
846        assert_eq!(t.host(), t2.host());
847        assert_eq!(t.key_path(), t2.key_path());
848        assert_eq!(t.remote_workspace(), t2.remote_workspace());
849    }
850
851    #[test]
852    fn ssh_transport_implements_tool_transport() {
853        // Compile-time check: SshTransport must implement ToolTransport
854        fn assert_tool_transport<T: ToolTransport>() {}
855        assert_tool_transport::<SshTransport>();
856    }
857
858    #[test]
859    fn local_transport_implements_tool_transport() {
860        fn assert_tool_transport<T: ToolTransport>() {}
861        assert_tool_transport::<LocalTransport>();
862    }
863
864    /// Test that the SSH command construction includes ConnectTimeout.
865    #[test]
866    fn ssh_build_command_connect_timeout() {
867        let t = SshTransport::new("user@host", "/remote/workspace")
868            .with_connect_timeout(Duration::from_secs(15));
869        let cmd = t.build_ssh_command("echo test");
870        let args: Vec<String> = cmd
871            .as_std()
872            .get_args()
873            .map(|s| s.to_string_lossy().to_string())
874            .collect();
875
876        assert!(args.contains(&"ConnectTimeout=15".to_string()));
877    }
878
879    /// Test that the SSH command construction includes UserKnownHostsFile=/dev/null.
880    #[test]
881    fn ssh_build_command_known_hosts() {
882        let t = SshTransport::new("user@host", "/remote/workspace");
883        let cmd = t.build_ssh_command("echo test");
884        let args: Vec<String> = cmd
885            .as_std()
886            .get_args()
887            .map(|s| s.to_string_lossy().to_string())
888            .collect();
889
890        assert!(args.contains(&"UserKnownHostsFile=/dev/null".to_string()));
891    }
892}