Skip to main content

p4cli_20251/
lib.rs

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