Skip to main content

adb_wire/
lib.rs

1//! Async ADB wire protocol client over TCP.
2//!
3//! Talks directly to the ADB server (`localhost:5037`) using the
4//! [ADB protocol](https://android.googlesource.com/platform/packages/modules/adb/+/refs/heads/main/OVERVIEW.TXT)
5//! without spawning any child processes.
6//!
7//! # Quick start
8//!
9//! ```no_run
10//! use adb_wire::AdbWire;
11//!
12//! #[tokio::main(flavor = "current_thread")]
13//! async fn main() {
14//!     let adb = AdbWire::new("emulator-5554");
15//!
16//!     // Run a command — returns stdout, stderr, and exit code
17//!     let result = adb.shell("getprop ro.build.version.sdk").await.unwrap();
18//!     println!("SDK version: {}", result.stdout_str());
19//!
20//!     // Check exit status
21//!     let result = adb.shell("ls /nonexistent").await.unwrap();
22//!     if !result.success() {
23//!         eprintln!("exit {}: {}", result.exit_code, result.stderr_str());
24//!     }
25//!
26//!     // Fire-and-forget (returns immediately, command runs on device)
27//!     adb.shell_detach("input tap 500 500").await.unwrap();
28//!
29//!     // Stream output incrementally (e.g. logcat)
30//!     use tokio::io::{AsyncBufReadExt, BufReader};
31//!     let stream = adb.shell_stream("logcat -d").await.unwrap();
32//!     let mut lines = BufReader::new(stream).lines();
33//!     while let Some(line) = lines.next_line().await.unwrap() {
34//!         println!("{line}");
35//!     }
36//!
37//!     // File transfer
38//!     adb.push_file("local.txt", "/sdcard/remote.txt").await.unwrap();
39//!     adb.pull_file("/sdcard/remote.txt", "downloaded.txt").await.unwrap();
40//!
41//!     // Check if a remote file exists
42//!     let st = adb.stat("/sdcard/remote.txt").await.unwrap();
43//!     println!("exists={} size={}", st.exists(), st.size);
44//!
45//!     // Install an APK
46//!     adb.install("app.apk").await.unwrap();
47//! }
48//! ```
49//!
50//! # Host commands
51//!
52//! Commands that target the ADB server (not a specific device) are free
53//! functions:
54//!
55//! ```no_run
56//! use adb_wire::{list_devices, DEFAULT_SERVER};
57//!
58//! #[tokio::main(flavor = "current_thread")]
59//! async fn main() {
60//!     for (serial, state) in list_devices(DEFAULT_SERVER).await.unwrap() {
61//!         println!("{serial}\t{state}");
62//!     }
63//! }
64//! ```
65
66mod error;
67mod shell;
68mod sync;
69mod wire;
70
71pub use error::{Error, Result};
72pub use shell::{ShellOutput, ShellStream};
73pub use sync::{DirEntry, RemoteStat};
74
75use std::net::SocketAddr;
76use std::time::Duration;
77
78use tokio::net::TcpStream;
79
80/// Default ADB server address (`127.0.0.1:5037`).
81pub const DEFAULT_SERVER: SocketAddr =
82    SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)), 5037);
83
84// -- Host-level (device-independent) functions --------------------------------
85
86/// Send a host command to an ADB server and read the length-prefixed response.
87///
88/// These commands don't target a specific device. Examples:
89/// `"host:version"`, `"host:devices"`, `"host:track-devices"`.
90pub async fn host_command(server: SocketAddr, cmd: &str) -> Result<String> {
91    use tokio::io::AsyncReadExt;
92    let mut stream = tokio::time::timeout(
93        Duration::from_secs(2),
94        TcpStream::connect(server),
95    )
96    .await
97    .map_err(|_| Error::timed_out("connect timed out"))??;
98    stream.set_nodelay(true)?;
99
100    wire::send(&mut stream, cmd).await?;
101    wire::read_okay(&mut stream).await?;
102
103    // Response length is bounded by read_hex_len (max 1 MiB).
104    let len = wire::read_hex_len(&mut stream).await?;
105    let mut buf = vec![0u8; len];
106    stream.read_exact(&mut buf).await?;
107    String::from_utf8(buf)
108        .map_err(|e| Error::Protocol(format!("invalid utf-8 in response: {e}")))
109}
110
111/// Connect a TCP/IP device to the ADB server.
112///
113/// Equivalent to `adb connect <addr>`. The `addr` should be `"host:port"`,
114/// e.g. `"192.168.1.100:5555"`.
115///
116/// ```no_run
117/// use adb_wire::{connect_device, DEFAULT_SERVER};
118///
119/// # async fn example() {
120/// connect_device(DEFAULT_SERVER, "192.168.1.100:5555").await.unwrap();
121/// # }
122/// ```
123pub async fn connect_device(server: SocketAddr, addr: &str) -> Result<String> {
124    host_command(server, &format!("host:connect:{addr}")).await
125}
126
127/// Disconnect a TCP/IP device from the ADB server.
128///
129/// Equivalent to `adb disconnect <addr>`.
130///
131/// ```no_run
132/// use adb_wire::{disconnect_device, DEFAULT_SERVER};
133///
134/// # async fn example() {
135/// disconnect_device(DEFAULT_SERVER, "192.168.1.100:5555").await.unwrap();
136/// # }
137/// ```
138pub async fn disconnect_device(server: SocketAddr, addr: &str) -> Result<String> {
139    host_command(server, &format!("host:disconnect:{addr}")).await
140}
141
142/// List connected devices. Returns `(serial, state)` pairs.
143///
144/// `state` is typically `"device"`, `"offline"`, or `"unauthorized"`.
145pub async fn list_devices(server: SocketAddr) -> Result<Vec<(String, String)>> {
146    let raw = host_command(server, "host:devices").await?;
147    Ok(parse_device_list(&raw))
148}
149
150fn parse_device_list(raw: &str) -> Vec<(String, String)> {
151    raw.lines()
152        .filter_map(|line| {
153            let (serial, state) = line.split_once('\t')?;
154            Some((serial.to_string(), state.to_string()))
155        })
156        .collect()
157}
158
159/// Append a single-quoted, shell-safe version of `s` to `out`.
160///
161/// Wraps the value in single quotes, escaping any embedded single quotes
162/// with the `'\''` idiom. This prevents shell metacharacter injection.
163fn shell_quote_into(out: &mut String, s: &str) {
164    out.push('\'');
165    for ch in s.chars() {
166        if ch == '\'' {
167            out.push_str("'\\''");
168        } else {
169            out.push(ch);
170        }
171    }
172    out.push('\'');
173}
174
175// -- Device client ------------------------------------------------------------
176
177/// Async ADB wire protocol client bound to a specific device.
178///
179/// Each method opens a fresh TCP connection to the ADB server, so this type
180/// holds no open connections. All fields are plain data, making `AdbWire`
181/// [`Send`], [`Sync`], and [`Clone`].
182#[derive(Clone, Debug)]
183pub struct AdbWire {
184    serial: String,
185    server: SocketAddr,
186    connect_timeout: Duration,
187    read_timeout: Duration,
188}
189
190impl AdbWire {
191    /// Create a client for the given device serial (e.g. `"emulator-5554"`,
192    /// `"127.0.0.1:5555"`, `"R5CR1234567"`).
193    pub fn new(serial: &str) -> Self {
194        Self {
195            serial: serial.to_string(),
196            server: DEFAULT_SERVER,
197            connect_timeout: Duration::from_secs(2),
198            read_timeout: Duration::from_secs(30),
199        }
200    }
201
202    /// Use a custom ADB server address (default: `127.0.0.1:5037`).
203    pub fn server(mut self, addr: SocketAddr) -> Self {
204        self.server = addr;
205        self
206    }
207
208    /// Set the TCP connect timeout (default: 2s).
209    pub fn connect_timeout(mut self, dur: Duration) -> Self {
210        self.connect_timeout = dur;
211        self
212    }
213
214    /// Set the read timeout for buffered methods like [`shell`](Self::shell)
215    /// (default: 30s). For streaming methods, manage timeouts on the returned
216    /// stream directly.
217    pub fn read_timeout(mut self, dur: Duration) -> Self {
218        self.read_timeout = dur;
219        self
220    }
221
222    /// Get the device serial.
223    pub fn serial(&self) -> &str {
224        &self.serial
225    }
226
227    // -- Shell ----------------------------------------------------------------
228
229    /// Run a shell command, returning separated stdout, stderr, and exit code.
230    ///
231    /// ```no_run
232    /// # use adb_wire::AdbWire;
233    /// # async fn example() {
234    /// let adb = AdbWire::new("emulator-5554");
235    /// let result = adb.shell("ls /sdcard").await.unwrap();
236    /// if result.success() {
237    ///     println!("{}", result.stdout_str());
238    /// } else {
239    ///     eprintln!("exit {}: {}", result.exit_code, result.stderr_str());
240    /// }
241    /// # }
242    /// ```
243    pub async fn shell(&self, cmd: &str) -> Result<ShellOutput> {
244        let stream = self.connect_shell(cmd).await?;
245        self.with_timeout(shell::read_shell(stream)).await?
246    }
247
248    /// Run a shell command and return a streaming reader.
249    ///
250    /// The returned [`ShellStream`] implements [`AsyncRead`](tokio::io::AsyncRead)
251    /// yielding stdout bytes. Use [`collect_output`](ShellStream::collect_output)
252    /// to buffer everything, or read incrementally for long-running commands.
253    ///
254    /// ```no_run
255    /// # use adb_wire::AdbWire;
256    /// # async fn example() {
257    /// use tokio::io::{AsyncBufReadExt, BufReader};
258    ///
259    /// let adb = AdbWire::new("emulator-5554");
260    /// let stream = adb.shell_stream("logcat").await.unwrap();
261    /// let mut lines = BufReader::new(stream).lines();
262    /// while let Some(line) = lines.next_line().await.unwrap() {
263    ///     println!("{line}");
264    /// }
265    /// # }
266    /// ```
267    pub async fn shell_stream(&self, cmd: &str) -> Result<ShellStream> {
268        Ok(ShellStream::new(self.connect_shell(cmd).await?))
269    }
270
271    /// Send a shell command without waiting for output.
272    ///
273    /// Opens a shell connection and immediately drops it. The ADB server
274    /// will still deliver the command to the device, but the command may
275    /// not have *started* on the device by the time this method returns.
276    ///
277    /// **Warning:** Because the connection is dropped immediately, there is
278    /// no guarantee the command will execute. If the device has not received
279    /// the command before the TCP connection closes, it will be lost. Use
280    /// [`shell`](Self::shell) if you need confirmation that the command ran.
281    ///
282    /// Best suited for input commands (tap, swipe, keyevent) where occasional
283    /// drops are acceptable.
284    pub async fn shell_detach(&self, cmd: &str) -> Result<()> {
285        let mut stream = self.connect_shell(cmd).await?;
286        // Wait briefly for the server to acknowledge, giving it time to
287        // forward the command to the device before we drop the connection.
288        let mut header = [0u8; 5];
289        let _ = tokio::time::timeout(
290            Duration::from_millis(50),
291            tokio::io::AsyncReadExt::read_exact(&mut stream, &mut header),
292        )
293        .await;
294        Ok(())
295    }
296
297    // -- File transfer (sync protocol) ----------------------------------------
298
299    /// Query file metadata on the device.
300    ///
301    /// Returns a [`RemoteStat`] with mode, size, and modification time.
302    /// If the path does not exist, all fields will be zero —
303    /// check with [`RemoteStat::exists()`].
304    ///
305    /// ```no_run
306    /// # use adb_wire::AdbWire;
307    /// # async fn example() {
308    /// let adb = AdbWire::new("emulator-5554");
309    /// let st = adb.stat("/sdcard/Download/test.txt").await.unwrap();
310    /// if st.exists() {
311    ///     println!("size: {} bytes, mode: {:o}", st.size, st.mode);
312    /// }
313    /// # }
314    /// ```
315    pub async fn stat(&self, remote_path: &str) -> Result<RemoteStat> {
316        // Try STAT2 first for large file / extended metadata support.
317        let mut stream = self.connect_cmd("sync:").await?;
318        match sync::stat2_sync(&mut stream, remote_path).await {
319            Ok(st) => Ok(st),
320            Err(_) => {
321                // Fall back to STAT v1 on a fresh sync connection.
322                let mut stream = self.connect_cmd("sync:").await?;
323                sync::stat_v1_sync(&mut stream, remote_path).await
324            }
325        }
326    }
327
328    /// List files in a remote directory.
329    ///
330    /// Returns entries excluding `.` and `..`. Uses the sync LIST protocol.
331    ///
332    /// ```no_run
333    /// # use adb_wire::AdbWire;
334    /// # async fn example() {
335    /// let adb = AdbWire::new("emulator-5554");
336    /// for entry in adb.list_dir("/sdcard").await.unwrap() {
337    ///     println!("{}\t{} bytes", entry.name, entry.size);
338    /// }
339    /// # }
340    /// ```
341    pub async fn list_dir(&self, remote_path: &str) -> Result<Vec<DirEntry>> {
342        let mut stream = self.connect_cmd("sync:").await?;
343        sync::list_sync(&mut stream, remote_path).await
344    }
345
346    /// Pull a file from the device, writing its contents to `writer`.
347    ///
348    /// Returns the number of bytes written.
349    pub async fn pull(
350        &self,
351        remote_path: &str,
352        writer: &mut (impl tokio::io::AsyncWrite + Unpin),
353    ) -> Result<u64> {
354        let mut stream = self.connect_cmd("sync:").await?;
355        sync::pull_sync(&mut stream, remote_path, writer).await
356    }
357
358    /// Pull a file from the device to a local path.
359    pub async fn pull_file(
360        &self,
361        remote_path: &str,
362        local_path: impl AsRef<std::path::Path>,
363    ) -> Result<u64> {
364        let mut f = tokio::fs::File::create(local_path.as_ref()).await?;
365        self.pull(remote_path, &mut f).await
366    }
367
368    /// Push data from `reader` to a file on the device.
369    ///
370    /// `mode` is the Unix file permission as an octal value (e.g. `0o644`,
371    /// `0o755`). `mtime` is seconds since the Unix epoch.
372    pub async fn push(
373        &self,
374        remote_path: &str,
375        mode: u32,
376        mtime: u32,
377        reader: &mut (impl tokio::io::AsyncRead + Unpin),
378    ) -> Result<()> {
379        let mut stream = self.connect_cmd("sync:").await?;
380        sync::push_sync(&mut stream, remote_path, mode, mtime, reader).await
381    }
382
383    /// Push a local file to the device with mode `0o644`.
384    pub async fn push_file(
385        &self,
386        local_path: impl AsRef<std::path::Path>,
387        remote_path: &str,
388    ) -> Result<()> {
389        self.push_file_with_mode(local_path, remote_path, 0o644).await
390    }
391
392    /// Push a local file to the device with a custom Unix file mode.
393    ///
394    /// ```no_run
395    /// # use adb_wire::AdbWire;
396    /// # async fn example() {
397    /// let adb = AdbWire::new("emulator-5554");
398    /// adb.push_file_with_mode("run.sh", "/data/local/tmp/run.sh", 0o755).await.unwrap();
399    /// # }
400    /// ```
401    pub async fn push_file_with_mode(
402        &self,
403        local_path: impl AsRef<std::path::Path>,
404        remote_path: &str,
405        mode: u32,
406    ) -> Result<()> {
407        let path = local_path.as_ref();
408        let meta = tokio::fs::metadata(path).await?;
409        let mtime = meta.modified().ok()
410            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
411            .map_or(0, |d| d.as_secs() as u32);
412        let mut f = tokio::fs::File::open(path).await?;
413        self.push(remote_path, mode, mtime, &mut f).await
414    }
415
416    // -- Install --------------------------------------------------------------
417
418    /// Install an APK on the device. Equivalent to `adb install <path>`.
419    ///
420    /// ```no_run
421    /// # use adb_wire::AdbWire;
422    /// # async fn example() {
423    /// let adb = AdbWire::new("emulator-5554");
424    /// adb.install("app-debug.apk").await.unwrap();
425    /// # }
426    /// ```
427    pub async fn install(&self, local_apk: impl AsRef<std::path::Path>) -> Result<()> {
428        self.install_with_args(local_apk, &[]).await
429    }
430
431    /// Install an APK with additional `pm install` flags.
432    ///
433    /// ```no_run
434    /// # use adb_wire::AdbWire;
435    /// # async fn example() {
436    /// let adb = AdbWire::new("emulator-5554");
437    /// adb.install_with_args("app.apk", &["-r", "-d"]).await.unwrap();
438    /// # }
439    /// ```
440    pub async fn install_with_args(
441        &self,
442        local_apk: impl AsRef<std::path::Path>,
443        args: &[&str],
444    ) -> Result<()> {
445        let local = local_apk.as_ref();
446        let filename = local
447            .file_name()
448            .and_then(|n| n.to_str())
449            .unwrap_or("install.apk");
450
451        // Reject filenames that could escape the shell or path.
452        if filename.contains('/') || filename.contains('\0') || filename.contains('\'') {
453            return Err(Error::Adb(format!("unsafe filename: {filename}")));
454        }
455
456        let remote = format!("/data/local/tmp/{filename}");
457        self.push_file_with_mode(local, &remote, 0o644).await?;
458
459        let mut cmd = String::from("pm install");
460        for arg in args {
461            cmd.push(' ');
462            shell_quote_into(&mut cmd, arg);
463        }
464        cmd.push(' ');
465        shell_quote_into(&mut cmd, &remote);
466
467        let result = self.shell(&cmd).await?;
468
469        // Best-effort cleanup of the temp APK.
470        let cleanup_ok = self
471            .shell(&format!("rm -f '{remote}'"))
472            .await
473            .map(|r| r.success())
474            .unwrap_or(false);
475
476        if result.success() {
477            Ok(())
478        } else {
479            let output = if result.stderr.is_empty() {
480                result.stdout_str()
481            } else {
482                result.stderr_str()
483            };
484            let mut msg = format!("pm install failed (exit {}): {output}", result.exit_code);
485            if !cleanup_ok {
486                msg.push_str(&format!(" (cleanup of {remote} also failed)"));
487            }
488            Err(Error::Adb(msg))
489        }
490    }
491
492    // -- Port forwarding ------------------------------------------------------
493
494    /// Set up port forwarding from a local socket to the device.
495    ///
496    /// `local` and `remote` are ADB socket specs, e.g. `"tcp:8080"`,
497    /// `"localabstract:chrome_devtools_remote"`.
498    pub async fn forward(&self, local: &str, remote: &str) -> Result<()> {
499        let mut stream = self.connect_raw().await?;
500        let cmd = format!("host-serial:{}:forward:{};{}", self.serial, local, remote);
501        wire::send(&mut stream, &cmd).await?;
502        wire::read_okay(&mut stream).await?;
503        wire::read_okay(&mut stream).await?; // second OKAY after setup
504        Ok(())
505    }
506
507    /// Set up reverse port forwarding from the device to a local socket.
508    pub async fn reverse(&self, remote: &str, local: &str) -> Result<()> {
509        let mut stream = self.connect_transport().await?;
510        let cmd = format!("reverse:forward:{remote};{local}");
511        wire::send(&mut stream, &cmd).await?;
512        wire::read_okay(&mut stream).await?;
513        Ok(())
514    }
515
516    // -- Lifecycle ------------------------------------------------------------
517
518    /// Wait until the device is online.
519    ///
520    /// Blocks until the ADB server reports the device as available.
521    /// Equivalent to `adb -s <serial> wait-for-device`.
522    ///
523    /// ```no_run
524    /// # use adb_wire::AdbWire;
525    /// # async fn example() {
526    /// let adb = AdbWire::new("192.168.1.100:5555");
527    /// adb.wait_for_device().await.unwrap();
528    /// println!("device online");
529    /// # }
530    /// ```
531    pub async fn wait_for_device(&self) -> Result<()> {
532        let mut stream = self.connect_raw().await?;
533        let cmd = format!("host-serial:{}:wait-for-any-device", self.serial);
534        wire::send(&mut stream, &cmd).await?;
535        wire::read_okay(&mut stream).await?;
536        // Server sends a second OKAY when the device reaches the requested state.
537        wire::read_okay(&mut stream).await?;
538        Ok(())
539    }
540
541    /// Wait until the device has finished booting.
542    ///
543    /// Waits for the device to come online, then polls `sys.boot_completed`
544    /// until it reports `1`.
545    ///
546    /// ```no_run
547    /// # use adb_wire::AdbWire;
548    /// # async fn example() {
549    /// let adb = AdbWire::new("emulator-5554");
550    /// adb.wait_for_boot().await.unwrap();
551    /// println!("device booted");
552    /// # }
553    /// ```
554    pub async fn wait_for_boot(&self) -> Result<()> {
555        self.wait_for_device().await?;
556        loop {
557            if let Ok(out) = self.shell("getprop sys.boot_completed").await {
558                if out.stdout_str() == "1" {
559                    return Ok(());
560                }
561            }
562            tokio::time::sleep(Duration::from_secs(1)).await;
563        }
564    }
565
566    // -- Connection internals -------------------------------------------------
567
568    async fn connect_transport(&self) -> Result<TcpStream> {
569        let mut stream = self.connect_raw().await?;
570        wire::send(&mut stream, &format!("host:transport:{}", self.serial)).await?;
571        wire::read_okay(&mut stream).await?;
572        Ok(stream)
573    }
574
575    async fn connect_cmd(&self, cmd: &str) -> Result<TcpStream> {
576        let mut stream = self.connect_transport().await?;
577        wire::send(&mut stream, cmd).await?;
578        wire::read_okay(&mut stream).await?;
579        Ok(stream)
580    }
581
582    async fn connect_shell(&self, cmd: &str) -> Result<TcpStream> {
583        self.connect_cmd(&format!("shell,v2,raw:{cmd}")).await
584    }
585
586    async fn with_timeout<F: std::future::Future>(&self, f: F) -> Result<F::Output> {
587        tokio::time::timeout(self.read_timeout, f)
588            .await
589            .map_err(|_| Error::timed_out("read timed out"))
590    }
591
592    async fn connect_raw(&self) -> Result<TcpStream> {
593        let stream = tokio::time::timeout(
594            self.connect_timeout,
595            TcpStream::connect(self.server),
596        )
597        .await
598        .map_err(|_| Error::timed_out("connect timed out"))??;
599        stream.set_nodelay(true)?;
600        Ok(stream)
601    }
602}
603
604// -- Tests --------------------------------------------------------------------
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use std::io::Cursor;
610
611    #[test]
612    fn new_defaults() {
613        let adb = AdbWire::new("emulator-5554");
614        assert_eq!(adb.serial(), "emulator-5554");
615        assert_eq!(adb.server, DEFAULT_SERVER);
616        assert_eq!(adb.read_timeout, Duration::from_secs(30));
617    }
618
619    #[test]
620    fn builder() {
621        let addr = SocketAddr::from(([192, 168, 1, 100], 5037));
622        let adb = AdbWire::new("device123")
623            .server(addr)
624            .connect_timeout(Duration::from_millis(500))
625            .read_timeout(Duration::from_secs(60));
626        assert_eq!(adb.server, addr);
627        assert_eq!(adb.connect_timeout, Duration::from_millis(500));
628        assert_eq!(adb.read_timeout, Duration::from_secs(60));
629    }
630
631    #[tokio::test]
632    async fn send_format() {
633        let mut buf = Vec::new();
634        wire::send(&mut buf, "host:version").await.unwrap();
635        assert_eq!(&buf, b"000chost:version");
636    }
637
638    #[tokio::test]
639    async fn okay_response() {
640        let mut cur = Cursor::new(b"OKAY".to_vec());
641        assert!(wire::read_okay(&mut cur).await.is_ok());
642    }
643
644    #[tokio::test]
645    async fn fail_response() {
646        let mut cur = Cursor::new(b"FAIL0005nope!".to_vec());
647        let err = wire::read_okay(&mut cur).await.unwrap_err();
648        assert!(matches!(err, Error::Adb(msg) if msg == "nope!"));
649    }
650
651    #[tokio::test]
652    async fn hex_len() {
653        let mut cur = Cursor::new(b"001f".to_vec());
654        assert_eq!(wire::read_hex_len(&mut cur).await.unwrap(), 31);
655    }
656
657    #[tokio::test]
658    async fn bad_status() {
659        let mut cur = Cursor::new(b"WHAT".to_vec());
660        assert!(matches!(
661            wire::read_okay(&mut cur).await.unwrap_err(),
662            Error::Protocol(_)
663        ));
664    }
665
666    #[test]
667    fn parse_devices() {
668        let raw = "emulator-5554\tdevice\nR5CR1234567\toffline\n";
669        let devices = parse_device_list(raw);
670        assert_eq!(
671            devices,
672            vec![
673                ("emulator-5554".into(), "device".into()),
674                ("R5CR1234567".into(), "offline".into()),
675            ]
676        );
677    }
678
679    #[test]
680    fn parse_devices_empty() {
681        assert!(parse_device_list("").is_empty());
682        assert!(parse_device_list("\n").is_empty());
683    }
684
685    #[test]
686    fn shell_quote_simple() {
687        let mut out = String::new();
688        shell_quote_into(&mut out, "hello");
689        assert_eq!(out, "'hello'");
690    }
691
692    #[test]
693    fn shell_quote_with_spaces_and_semicolons() {
694        let mut out = String::new();
695        shell_quote_into(&mut out, "foo; rm -rf /");
696        assert_eq!(out, "'foo; rm -rf /'");
697    }
698
699    #[test]
700    fn shell_quote_with_single_quotes() {
701        let mut out = String::new();
702        shell_quote_into(&mut out, "it's");
703        assert_eq!(out, "'it'\\''s'");
704    }
705}