Skip to main content

p4cli_20251/
lib.rs

1use std::io::{BufReader, Read, Write};
2use std::path::PathBuf;
3use std::process::{Child, Command, ExitStatus, Stdio};
4use std::thread;
5use std::time::Duration;
6
7/// A P4 CLI wrapper that extracts the embedded p4 binary to an isolated temporary directory.
8///
9/// Each `P4Cli` instance gets its own temp directory, eliminating cross-process races.
10/// The temp directory is cleaned up on `Drop`.
11///
12/// # Example
13///
14/// ```rust
15/// use p4cli_20251::P4Cli;
16///
17/// fn main() -> std::io::Result<()> {
18///     let p4: P4Cli = P4Cli::new()?;
19///     let output: p4cli_20251::P4Output = p4.run(&["--help"])?;
20///
21///     println!("exit: {}", output.exit_code());
22///     println!("{}", output.stdout_str()?);
23///     Ok(())
24/// }
25/// ```
26pub struct P4Cli {
27    bin_path: PathBuf,
28    _temp_dir: PathBuf,
29}
30
31/// Collected output from a single `p4` invocation.
32///
33/// Holds raw stdout/stderr bytes (supports binary content) and the exit code.
34/// The child process is guaranteed to have been reaped before this struct is returned.
35pub struct P4Output {
36    exit_code: i32,
37    stdout: Vec<u8>,
38    stderr: Vec<u8>,
39}
40
41impl P4Output {
42    pub fn exit_code(&self) -> i32 {
43        self.exit_code
44    }
45
46    /// Returns `true` if the exit code is `0`.
47    pub fn success(&self) -> bool {
48        self.exit_code == 0
49    }
50
51    /// Raw stdout bytes (may be binary).
52    pub fn stdout(&self) -> &[u8] {
53        &self.stdout
54    }
55
56    /// Raw stderr bytes (may be binary).
57    pub fn stderr(&self) -> &[u8] {
58        &self.stderr
59    }
60
61    /// Decode stdout as UTF-8.
62    pub fn stdout_str(&self) -> std::io::Result<&str> {
63        std::str::from_utf8(&self.stdout).map_err(std::io::Error::other)
64    }
65
66    /// Decode stderr as UTF-8.
67    pub fn stderr_str(&self) -> std::io::Result<&str> {
68        std::str::from_utf8(&self.stderr).map_err(std::io::Error::other)
69    }
70
71    /// Lines of stdout (UTF-8 text only).
72    pub fn stdout_lines(&self) -> std::io::Result<Vec<&str>> {
73        let s = self.stdout_str()?;
74        if s.is_empty() {
75            Ok(Vec::new())
76        } else {
77            Ok(s.lines().collect())
78        }
79    }
80
81    /// Lines of stderr (UTF-8 text only).
82    pub fn stderr_lines(&self) -> std::io::Result<Vec<&str>> {
83        let s = self.stderr_str()?;
84        if s.is_empty() {
85            Ok(Vec::new())
86        } else {
87            Ok(s.lines().collect())
88        }
89    }
90}
91
92impl std::fmt::Debug for P4Output {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.debug_struct("P4Output")
95            .field("exit_code", &self.exit_code)
96            .field("stdout_len", &self.stdout.len())
97            .field("stderr_len", &self.stderr.len())
98            .finish()
99    }
100}
101
102// ---------------------------------------------------------------------------
103// Streaming API
104// ---------------------------------------------------------------------------
105
106/// A single event yielded by [`P4Stream`].
107pub enum P4StreamEvent {
108    /// A chunk of stdout bytes (may be partial line / binary).
109    Stdout(Vec<u8>),
110    /// A chunk of stderr bytes (may be partial line / binary).
111    Stderr(Vec<u8>),
112    /// The process has exited with the given code.
113    Exit(i32),
114}
115
116impl P4StreamEvent {
117    /// Try to decode this event's payload as UTF-8. Returns `None` for
118    /// [`Exit`](P4StreamEvent::Exit) or non-UTF-8 data.
119    pub fn as_utf8(&self) -> Option<&str> {
120        match self {
121            P4StreamEvent::Stdout(data) | P4StreamEvent::Stderr(data) => {
122                std::str::from_utf8(data).ok()
123            }
124            P4StreamEvent::Exit(_) => None,
125        }
126    }
127}
128
129impl std::fmt::Display for P4StreamEvent {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        match self {
132            P4StreamEvent::Stdout(data) | P4StreamEvent::Stderr(data) => {
133                if let Ok(text) = std::str::from_utf8(data) {
134                    write!(f, "{text}")
135                } else {
136                    write!(f, "<{} bytes>", data.len())
137                }
138            }
139            P4StreamEvent::Exit(code) => write!(f, "(exit {code})"),
140        }
141    }
142}
143
144/// A streaming iterator over the output of a `p4` process.
145///
146/// Stdout and stderr are read concurrently by two OS threads as raw byte
147/// chunks (~64 KB each) and merged into a single stream. The process is
148/// automatically reaped when the stream is exhausted. Dropping the stream
149/// mid-way kills the process and cleans up all resources.
150///
151/// The final item yielded is always [`P4StreamEvent::Exit`] with the exit
152/// code, unless the stream is dropped early.
153///
154/// # Encoding
155///
156/// Raw chunks are yielded — the caller decides the encoding.
157/// Use [`P4StreamEvent::as_utf8`] for a quick UTF-8 check, or
158/// `String::from_utf8()` / `encoding_rs` for full control.
159///
160/// # Example
161///
162/// ```rust
163/// use p4cli_20251::{P4Cli, P4StreamEvent};
164///
165/// fn main() -> std::io::Result<()> {
166///     let p4: P4Cli = P4Cli::new()?;
167///     for event in p4.stream(&["--help"])? {
168///         match event? {
169///             P4StreamEvent::Stdout(chunk) => {
170///                 if let Ok(text) = std::str::from_utf8(&chunk) {
171///                     print!("{text}");
172///                 }
173///             }
174///             P4StreamEvent::Stderr(chunk) => {
175///                 if let Ok(text) = std::str::from_utf8(&chunk) {
176///                     eprint!("{text}");
177///                 }
178///             }
179///             P4StreamEvent::Exit(code) => println!("exit {code}"),
180///         }
181///     }
182///     Ok(())
183/// }
184/// ```
185pub struct P4Stream {
186    rx: std::sync::mpsc::Receiver<std::io::Result<P4StreamEvent>>,
187    child: Option<Child>,
188    /// Killing the child closes the pipes, which unblocks the reader
189    /// threads and lets them exit naturally.
190    #[allow(dead_code)]
191    handles: Vec<thread::JoinHandle<()>>,
192    exhausted: bool,
193}
194
195impl Iterator for P4Stream {
196    type Item = std::io::Result<P4StreamEvent>;
197
198    fn next(&mut self) -> Option<Self::Item> {
199        if self.exhausted {
200            return None;
201        }
202        match self.rx.recv() {
203            Ok(item) => Some(item),
204            Err(_) => {
205                // All senders (reader threads) have finished → reap child.
206                self.exhausted = true;
207                let code = self
208                    .child
209                    .take()
210                    .and_then(|mut c| c.wait().ok())
211                    .and_then(|s| s.code())
212                    .unwrap_or(-1);
213                Some(Ok(P4StreamEvent::Exit(code)))
214            }
215        }
216    }
217}
218
219impl Drop for P4Stream {
220    fn drop(&mut self) {
221        // Kill child if still owned (stream was dropped mid-way).
222        if let Some(ref mut child) = self.child {
223            let _ = child.kill();
224            let _ = child.wait();
225        }
226        // The reader threads will now hit EOF and exit on their own.
227    }
228}
229
230// ---------------------------------------------------------------------------
231// Temporary-directory helpers
232// ---------------------------------------------------------------------------
233
234/// Decompress the embedded zstd payload and write it to a fresh per-instance
235/// directory. No persistent cache — each instance gets its own isolated copy.
236fn write_p4_cli_to_disk() -> std::io::Result<(PathBuf, PathBuf)> {
237    let zst_data = get_p4_cli_zst();
238    let binary_data = decompress_zst(&zst_data)?;
239
240    let base = std::env::temp_dir().join("p4cli-20251");
241    std::fs::create_dir_all(&base)?;
242
243    let dir = base.join(format!(
244        "{}_{}",
245        std::process::id(),
246        std::time::SystemTime::now()
247            .duration_since(std::time::UNIX_EPOCH)
248            .map(|d| d.as_nanos())
249            .unwrap_or(0)
250    ));
251    std::fs::create_dir(&dir)?;
252
253    let bin_path = dir.join("p4_binary");
254    let tmp_path = dir.join(".tmp");
255
256    // Atomic write: write to .tmp first, then rename.
257    {
258        let mut file = std::fs::File::create(&tmp_path)?;
259        file.write_all(&binary_data)?;
260        file.sync_all()?;
261    }
262    std::fs::rename(&tmp_path, &bin_path)?;
263    set_executable_perms(&bin_path)?;
264
265    Ok((bin_path, dir))
266}
267
268fn decompress_zst(zst_data: &[u8]) -> std::io::Result<Vec<u8>> {
269    let mut decoder = zstd::stream::Decoder::new(zst_data)?;
270    let mut buf = Vec::new();
271    std::io::copy(&mut decoder, &mut buf)?;
272    Ok(buf)
273}
274
275#[cfg(unix)]
276fn set_executable_perms(path: &std::path::Path) -> std::io::Result<()> {
277    use std::os::unix::fs::PermissionsExt;
278    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
279}
280
281#[cfg(not(unix))]
282fn set_executable_perms(_path: &std::path::Path) -> std::io::Result<()> {
283    Ok(())
284}
285
286// ---------------------------------------------------------------------------
287// Platform-specific binary accessors
288// ---------------------------------------------------------------------------
289
290fn get_p4_cli_zst() -> Vec<u8> {
291    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
292    {
293        use p4cli_20251_win_x64::get_p4_cli_zst;
294        get_p4_cli_zst()
295    }
296
297    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
298    {
299        use p4cli_20251_mac_arm64::get_p4_cli_zst;
300        get_p4_cli_zst()
301    }
302
303    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
304    {
305        use p4cli_20251_mac_x64::get_p4_cli_zst;
306        get_p4_cli_zst()
307    }
308
309    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
310    {
311        use p4cli_20251_linux_x64::get_p4_cli_zst;
312        get_p4_cli_zst()
313    }
314
315    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
316    {
317        use p4cli_20251_linux_arm64::get_p4_cli_zst;
318        get_p4_cli_zst()
319    }
320
321    #[cfg(not(any(
322        all(target_os = "windows", target_arch = "x86_64"),
323        all(target_os = "macos", target_arch = "aarch64"),
324        all(target_os = "macos", target_arch = "x86_64"),
325        all(target_os = "linux", target_arch = "x86_64"),
326        all(target_os = "linux", target_arch = "aarch64")
327    )))]
328    {
329        compile_error!(format!(
330            "Unsupported platform: {}-{}",
331            std::env::consts::OS,
332            std::env::consts::ARCH
333        ));
334        Vec::new()
335    }
336}
337
338// ---------------------------------------------------------------------------
339// Builder for a single p4 invocation
340// ---------------------------------------------------------------------------
341
342/// Builder-style interface for running a single `p4` command.
343///
344/// Obtain one via [`P4Cli::command()`] and chain configuration calls
345/// before calling [`run`](P4Command::run).
346pub struct P4Command<'a> {
347    cli: &'a P4Cli,
348    args: Vec<std::ffi::OsString>,
349    timeout: Option<Duration>,
350    cwd: Option<PathBuf>,
351    envs: Vec<(std::ffi::OsString, std::ffi::OsString)>,
352    stdin_data: Option<Vec<u8>>,
353}
354
355impl<'a> P4Command<'a> {
356    fn new(cli: &'a P4Cli) -> Self {
357        Self {
358            cli,
359            args: Vec::new(),
360            timeout: None,
361            cwd: None,
362            envs: Vec::new(),
363            stdin_data: None,
364        }
365    }
366
367    /// Append a single argument.
368    pub fn arg(&mut self, arg: impl AsRef<std::ffi::OsStr>) -> &mut Self {
369        self.args.push(arg.as_ref().to_os_string());
370        self
371    }
372
373    /// Append all arguments from a slice.
374    pub fn args(&mut self, args: &[impl AsRef<std::ffi::OsStr>]) -> &mut Self {
375        self.args
376            .extend(args.iter().map(|a| a.as_ref().to_os_string()));
377        self
378    }
379
380    /// Maximum wall-clock time the process is allowed to run.
381    /// When exceeded the process is killed.
382    ///
383    /// **Note**: Only the direct child process is terminated,
384    /// not its descendants (no process-tree kill).
385    pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
386        self.timeout = Some(timeout);
387        self
388    }
389
390    /// Working directory for the child process.
391    pub fn cwd(&mut self, path: impl Into<PathBuf>) -> &mut Self {
392        self.cwd = Some(path.into());
393        self
394    }
395
396    /// Set an environment variable for the child process.
397    pub fn env(
398        &mut self,
399        key: impl Into<std::ffi::OsString>,
400        val: impl Into<std::ffi::OsString>,
401    ) -> &mut Self {
402        self.envs.push((key.into(), val.into()));
403        self
404    }
405
406    /// Provide data to be piped to the child's stdin.
407    pub fn stdin(&mut self, data: impl Into<Vec<u8>>) -> &mut Self {
408        self.stdin_data = Some(data.into());
409        self
410    }
411
412    /// Execute the command and collect output.
413    ///
414    /// Stdout and stderr are read concurrently in separate OS threads to
415    /// prevent pipe-full deadlocks. If a [`timeout`](Self::timeout) was set,
416    /// the process is killed once the deadline is reached.
417    pub fn run(&mut self) -> std::io::Result<P4Output> {
418        let mut cmd = Command::new(&self.cli.bin_path);
419        cmd.args(&self.args)
420            .stdout(Stdio::piped())
421            .stderr(Stdio::piped());
422
423        if self.stdin_data.is_some() {
424            cmd.stdin(Stdio::piped());
425        } else {
426            cmd.stdin(Stdio::null());
427        }
428
429        if let Some(ref cwd) = self.cwd {
430            cmd.current_dir(cwd);
431        }
432        for (k, v) in &self.envs {
433            cmd.env(k, v);
434        }
435
436        let mut child = cmd.spawn()?;
437
438        // Write stdin in a background thread if data was provided.
439        if let Some(data) = self.stdin_data.take()
440            && let Some(mut stdin) = child.stdin.take()
441        {
442            thread::spawn(move || {
443                let _ = stdin.write_all(&data);
444            });
445        }
446
447        let stdout = child
448            .stdout
449            .take()
450            .ok_or_else(|| std::io::Error::other("stdout was not captured"))?;
451        let stderr = child
452            .stderr
453            .take()
454            .ok_or_else(|| std::io::Error::other("stderr was not captured"))?;
455
456        // Read stdout and stderr concurrently in dedicated threads.
457        let stdout_handle = thread::spawn(move || {
458            let mut buf = Vec::new();
459            BufReader::new(stdout).read_to_end(&mut buf)?;
460            Ok::<_, std::io::Error>(buf)
461        });
462
463        let stderr_handle = thread::spawn(move || {
464            let mut buf = Vec::new();
465            BufReader::new(stderr).read_to_end(&mut buf)?;
466            Ok::<_, std::io::Error>(buf)
467        });
468
469        // Wait for the process (with optional timeout).
470        let exit_status = wait_process(&mut child, self.timeout)?;
471
472        let stdout_buf = stdout_handle
473            .join()
474            .map_err(|_| std::io::Error::other("stdout reader thread panicked"))?
475            .map_err(|e| std::io::Error::other(format!("stdout read failed: {e}")))?;
476        let stderr_buf = stderr_handle
477            .join()
478            .map_err(|_| std::io::Error::other("stderr reader thread panicked"))?
479            .map_err(|e| std::io::Error::other(format!("stderr read failed: {e}")))?;
480
481        Ok(P4Output {
482            exit_code: exit_status.code().unwrap_or(-1),
483            stdout: stdout_buf,
484            stderr: stderr_buf,
485        })
486    }
487
488    /// Run the command and return a streaming iterator over output lines.
489    ///
490    /// Stdout and stderr are read concurrently by two OS threads and merged
491    /// into a single stream. The final event is always
492    /// [`P4StreamEvent::Exit`] with the exit code (unless the stream is
493    /// dropped early).
494    ///
495    /// Unlike [`run`](Self::run), this method does **not** support
496    /// [`timeout`](Self::timeout) — the caller controls iteration and can
497    /// drop the stream to cancel.
498    pub fn stream(&mut self) -> std::io::Result<P4Stream> {
499        let mut cmd = Command::new(&self.cli.bin_path);
500        cmd.args(&self.args)
501            .stdout(Stdio::piped())
502            .stderr(Stdio::piped());
503
504        if self.stdin_data.is_some() {
505            cmd.stdin(Stdio::piped());
506        } else {
507            cmd.stdin(Stdio::null());
508        }
509        if let Some(ref cwd) = self.cwd {
510            cmd.current_dir(cwd);
511        }
512        for (k, v) in &self.envs {
513            cmd.env(k, v);
514        }
515
516        let mut child = cmd.spawn()?;
517
518        if let Some(data) = self.stdin_data.take()
519            && let Some(mut stdin) = child.stdin.take()
520        {
521            thread::spawn(move || {
522                let _ = stdin.write_all(&data);
523            });
524        }
525
526        let mut stdout = child
527            .stdout
528            .take()
529            .ok_or_else(|| std::io::Error::other("stdout was not captured"))?;
530        let mut stderr = child
531            .stderr
532            .take()
533            .ok_or_else(|| std::io::Error::other("stderr was not captured"))?;
534
535        let (tx, rx) = std::sync::mpsc::channel();
536        let mut handles = Vec::new();
537
538        // Stdout reader thread (64 KB chunks).
539        let tx_out = tx.clone();
540        handles.push(thread::spawn(move || {
541            let mut buf = vec![0u8; 65536];
542            loop {
543                let n = match stdout.read(&mut buf) {
544                    Ok(0) => break, // EOF
545                    Ok(n) => n,
546                    Err(e) => {
547                        let _ = tx_out.send(Err(e));
548                        break;
549                    }
550                };
551                if tx_out
552                    .send(Ok(P4StreamEvent::Stdout(buf[..n].to_vec())))
553                    .is_err()
554                {
555                    break;
556                }
557            }
558        }));
559
560        // Stderr reader thread (64 KB chunks).
561        let tx_err = tx.clone();
562        handles.push(thread::spawn(move || {
563            let mut buf = vec![0u8; 65536];
564            loop {
565                let n = match stderr.read(&mut buf) {
566                    Ok(0) => break,
567                    Ok(n) => n,
568                    Err(e) => {
569                        let _ = tx_err.send(Err(e));
570                        break;
571                    }
572                };
573                if tx_err
574                    .send(Ok(P4StreamEvent::Stderr(buf[..n].to_vec())))
575                    .is_err()
576                {
577                    break;
578                }
579            }
580        }));
581
582        Ok(P4Stream {
583            rx,
584            child: Some(child),
585            handles,
586            exhausted: false,
587        })
588    }
589}
590
591/// Block until `child` exits, optionally killing it after `timeout`.
592fn wait_process(child: &mut Child, timeout: Option<Duration>) -> std::io::Result<ExitStatus> {
593    match timeout {
594        None => child.wait(),
595        Some(t) => wait_with_timeout(child, t),
596    }
597}
598
599fn wait_with_timeout(child: &mut Child, timeout: Duration) -> std::io::Result<ExitStatus> {
600    let start = std::time::Instant::now();
601    loop {
602        if let Some(status) = child.try_wait()? {
603            return Ok(status);
604        }
605        if start.elapsed() >= timeout {
606            child.kill()?;
607            return child.wait();
608        }
609        thread::sleep(Duration::from_millis(50));
610    }
611}
612
613// ---------------------------------------------------------------------------
614// Public API
615// ---------------------------------------------------------------------------
616
617impl P4Cli {
618    /// Create a new `P4Cli` instance.
619    ///
620    /// The embedded p4 binary is decompressed and written to an isolated
621    /// temporary directory. Each call performs a fresh decompression;
622    /// there is no persistent cache (see security notes in the crate docs).
623    pub fn new() -> std::io::Result<Self> {
624        let (bin_path, temp_dir) = write_p4_cli_to_disk()?;
625        Ok(Self {
626            bin_path,
627            _temp_dir: temp_dir,
628        })
629    }
630
631    /// Convenience method: run p4 with the given arguments.
632    ///
633    /// This is equivalent to `self.command().args(args).run()`.
634    pub fn run<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Output> {
635        self.command().args(args).run()
636    }
637
638    /// Convenience method: stream p4 output with the given arguments.
639    ///
640    /// This is equivalent to `self.command().args(args).stream()`.
641    pub fn stream<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Stream> {
642        self.command().args(args).stream()
643    }
644
645    /// Obtain a [`P4Command`] builder for fine-grained control over
646    /// working directory, environment variables, stdin, and timeout.
647    ///
648    /// # Example
649    ///
650    /// ```rust
651    /// use p4cli_20251::P4Cli;
652    /// use std::time::Duration;
653    ///
654    /// fn main() -> std::io::Result<()> {
655    ///     let p4: P4Cli = P4Cli::new()?;
656    ///     let output: p4cli_20251::P4Output = p4
657    ///         .command()
658    ///         .arg("--help")
659    ///         .timeout(Duration::from_secs(10))
660    ///         .run()?;
661    ///
662    ///     if output.success() {
663    ///         println!("{}", output.stdout_str()?);
664    ///     }
665    ///     Ok(())
666    /// }
667    /// ```
668    pub fn command(&self) -> P4Command<'_> {
669        P4Command::new(self)
670    }
671}
672
673impl Drop for P4Cli {
674    fn drop(&mut self) {
675        let _ = std::fs::remove_dir_all(&self._temp_dir);
676    }
677}
678
679// ---------------------------------------------------------------------------
680// Tests
681// ---------------------------------------------------------------------------
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    #[test]
688    fn test_run_help() -> std::io::Result<()> {
689        let p4 = P4Cli::new()?;
690        let output = p4.run(&["--help"])?;
691        assert!(output.success(), "p4 --help should exit with 0");
692        let stdout = output.stdout_str()?;
693        assert!(
694            stdout.contains("Usage:"),
695            "expected --help output to contain 'Usage:'"
696        );
697        Ok(())
698    }
699
700    #[test]
701    fn test_run_error() -> std::io::Result<()> {
702        let p4 = P4Cli::new()?;
703        let output = p4.run(&["--nonexistent-flag"])?;
704        assert!(!output.success(), "unknown flag should exit non-zero");
705        let stderr = output.stderr_str()?;
706        assert!(
707            stderr.contains("Invalid option") || stderr.contains("error"),
708            "expected error output, got: {stderr}"
709        );
710        Ok(())
711    }
712
713    #[test]
714    fn test_multiple_instances() -> std::io::Result<()> {
715        let p4_a = P4Cli::new()?;
716        let p4_b = P4Cli::new()?;
717        assert!(p4_a.run(&["--help"])?.success());
718        assert!(p4_b.run(&["--help"])?.success());
719        Ok(())
720    }
721
722    #[test]
723    fn test_command_builder() -> std::io::Result<()> {
724        let p4 = P4Cli::new()?;
725        let output = p4.command().arg("--help").run()?;
726        assert!(output.success());
727        Ok(())
728    }
729
730    #[test]
731    fn test_timeout_kills() -> std::io::Result<()> {
732        let p4 = P4Cli::new()?;
733        // Run with a very short timeout – should be killed.
734        let output = p4
735            .command()
736            .arg("help")
737            .timeout(Duration::from_millis(1))
738            .run()?;
739        // After kill the exit code is typically non-zero (e.g. -1 or a signal number).
740        // We only verify the call does not hang.
741        assert!(!output.success() || output.exit_code() == 0);
742        Ok(())
743    }
744
745    #[test]
746    fn test_stream_help() -> std::io::Result<()> {
747        let p4 = P4Cli::new()?;
748        let mut saw_stdout = false;
749        let mut saw_exit = false;
750        for event in p4.stream(&["--help"])? {
751            match &event? {
752                P4StreamEvent::Stdout(data) => {
753                    if let Ok(text) = std::str::from_utf8(data)
754                        && text.contains("Usage:")
755                    {
756                        saw_stdout = true;
757                    }
758                }
759                P4StreamEvent::Stderr(_) => {}
760                P4StreamEvent::Exit(code) => {
761                    assert_eq!(*code, 0);
762                    saw_exit = true;
763                }
764            }
765        }
766        assert!(saw_stdout, "expected --help to contain 'Usage:'");
767        assert!(saw_exit, "expected Exit event");
768        Ok(())
769    }
770
771    #[test]
772    fn test_stream_error() -> std::io::Result<()> {
773        let p4 = P4Cli::new()?;
774        let mut saw_stderr = false;
775        let mut saw_exit = false;
776        for event in p4.stream(&["--nonexistent-flag"])? {
777            match &event? {
778                P4StreamEvent::Stdout(_) => {}
779                P4StreamEvent::Stderr(data) => {
780                    if let Ok(text) = std::str::from_utf8(data)
781                        && (text.contains("Invalid option") || text.contains("error"))
782                    {
783                        saw_stderr = true;
784                    }
785                }
786                P4StreamEvent::Exit(code) => {
787                    assert_ne!(*code, 0, "nonexistent flag should fail");
788                    saw_exit = true;
789                }
790            }
791        }
792        assert!(saw_stderr, "expected error output");
793        assert!(saw_exit, "expected Exit event");
794        Ok(())
795    }
796
797    #[test]
798    fn test_stream_drop_midway() -> std::io::Result<()> {
799        // Dropping the stream mid-way must not hang or panic.
800        let p4 = P4Cli::new()?;
801        let stream = p4.stream(&["--help"])?;
802        drop(stream);
803        Ok(())
804    }
805
806    #[test]
807    fn test_stream_builder() -> std::io::Result<()> {
808        let p4 = P4Cli::new()?;
809        let mut saw_exit = false;
810        for event in p4.command().arg("--help").stream()? {
811            if let P4StreamEvent::Exit(code) = event? {
812                assert_eq!(code, 0);
813                saw_exit = true;
814            }
815        }
816        assert!(saw_exit);
817        Ok(())
818    }
819}