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/// Per-instance temp dir via `tempfile`, cleaned up on Drop.
8///
9/// ```rust
10/// use p4cli_20251::P4Cli;
11/// fn main() -> std::io::Result<()> {
12///     let p4: P4Cli = P4Cli::new()?;
13///     let output: p4cli_20251::P4Output = p4.run(&["--help"])?;
14///     println!("exit: {}", output.exit_code());
15///     println!("{}", output.stdout_str()?);
16///     Ok(())
17/// }
18/// ```
19pub struct P4Cli {
20    bin_path: PathBuf,
21    _temp_dir: tempfile::TempDir,
22}
23
24/// Raw stdout/stderr bytes and exit code from a p4 invocation.
25pub struct P4Output {
26    exit_code: i32,
27    stdout: Vec<u8>,
28    stderr: Vec<u8>,
29}
30
31impl P4Output {
32    pub fn exit_code(&self) -> i32 {
33        self.exit_code
34    }
35
36    pub fn success(&self) -> bool {
37        self.exit_code == 0
38    }
39
40    pub fn stdout(&self) -> &[u8] {
41        &self.stdout
42    }
43
44    pub fn stderr(&self) -> &[u8] {
45        &self.stderr
46    }
47
48    pub fn stdout_str(&self) -> std::io::Result<&str> {
49        std::str::from_utf8(&self.stdout).map_err(std::io::Error::other)
50    }
51
52    pub fn stderr_str(&self) -> std::io::Result<&str> {
53        std::str::from_utf8(&self.stderr).map_err(std::io::Error::other)
54    }
55
56    pub fn stdout_lines(&self) -> std::io::Result<Vec<&str>> {
57        let s = self.stdout_str()?;
58        if s.is_empty() {
59            Ok(Vec::new())
60        } else {
61            Ok(s.lines().collect())
62        }
63    }
64
65    pub fn stderr_lines(&self) -> std::io::Result<Vec<&str>> {
66        let s = self.stderr_str()?;
67        if s.is_empty() {
68            Ok(Vec::new())
69        } else {
70            Ok(s.lines().collect())
71        }
72    }
73}
74
75impl std::fmt::Debug for P4Output {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("P4Output")
78            .field("exit_code", &self.exit_code)
79            .field("stdout_len", &self.stdout.len())
80            .field("stderr_len", &self.stderr.len())
81            .finish()
82    }
83}
84
85// ---------------------------------------------------------------------------
86// Streaming API
87// ---------------------------------------------------------------------------
88
89/// A single event yielded by [`P4Stream`].
90pub enum P4StreamEvent {
91    Stdout(Vec<u8>),
92    Stderr(Vec<u8>),
93    Exit(i32),
94}
95
96impl P4StreamEvent {
97    /// Try to decode this event's payload as UTF-8.
98    pub fn as_utf8(&self) -> Option<&str> {
99        match self {
100            P4StreamEvent::Stdout(data) | P4StreamEvent::Stderr(data) => {
101                std::str::from_utf8(data).ok()
102            }
103            P4StreamEvent::Exit(_) => None,
104        }
105    }
106}
107
108impl std::fmt::Display for P4StreamEvent {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            P4StreamEvent::Stdout(data) | P4StreamEvent::Stderr(data) => {
112                if let Ok(text) = std::str::from_utf8(data) {
113                    write!(f, "{text}")
114                } else {
115                    write!(f, "<{} bytes>", data.len())
116                }
117            }
118            P4StreamEvent::Exit(code) => write!(f, "(exit {code})"),
119        }
120    }
121}
122
123/// Merged stdout/stderr byte chunks (~64 KB each) as a single iterator.
124///
125/// The final item is always [`P4StreamEvent::Exit`]. Drop mid-way to kill.
126///
127/// ```rust
128/// use p4cli_20251::{P4Cli, P4StreamEvent};
129/// fn main() -> std::io::Result<()> {
130///     let p4: P4Cli = P4Cli::new()?;
131///     for event in p4.stream(&["--help"])? {
132///         match event? {
133///             P4StreamEvent::Stdout(chunk) => {
134///                 if let Ok(text) = std::str::from_utf8(&chunk) {
135///                     print!("{text}");
136///                 }
137///             }
138///             P4StreamEvent::Stderr(chunk) => {
139///                 if let Ok(text) = std::str::from_utf8(&chunk) {
140///                     eprint!("{text}");
141///                 }
142///             }
143///             P4StreamEvent::Exit(code) => println!("exit {code}"),
144///         }
145///     }
146///     Ok(())
147/// }
148/// ```
149pub struct P4Stream {
150    rx: std::sync::mpsc::Receiver<std::io::Result<P4StreamEvent>>,
151    child: Option<Child>,
152    #[allow(dead_code)]
153    handles: Vec<thread::JoinHandle<()>>,
154    exhausted: bool,
155}
156
157impl Iterator for P4Stream {
158    type Item = std::io::Result<P4StreamEvent>;
159
160    fn next(&mut self) -> Option<Self::Item> {
161        if self.exhausted {
162            return None;
163        }
164        match self.rx.recv() {
165            Ok(item) => Some(item),
166            Err(_) => {
167                self.exhausted = true;
168                let code = self
169                    .child
170                    .take()
171                    .and_then(|mut c| c.wait().ok())
172                    .and_then(|s| s.code())
173                    .unwrap_or(-1);
174                Some(Ok(P4StreamEvent::Exit(code)))
175            }
176        }
177    }
178}
179
180impl Drop for P4Stream {
181    fn drop(&mut self) {
182        if let Some(ref mut child) = self.child {
183            let _ = child.kill();
184            let _ = child.wait();
185        }
186    }
187}
188
189// ---------------------------------------------------------------------------
190// Binary extraction
191// ---------------------------------------------------------------------------
192
193fn write_p4_cli_to_disk() -> std::io::Result<(PathBuf, tempfile::TempDir)> {
194    let zst_data = get_p4_cli_zst();
195    let binary_data = decompress_zst(&zst_data)?;
196
197    let temp_dir = create_temp_dir()?;
198    let bin_path = temp_dir.path().join("p4_binary");
199    let tmp_path = temp_dir.path().join(".tmp");
200
201    {
202        let mut file = std::fs::File::create(&tmp_path)?;
203        file.write_all(&binary_data)?;
204        file.sync_all()?;
205    }
206    std::fs::rename(&tmp_path, &bin_path)?;
207    set_executable_perms(&bin_path)?;
208
209    Ok((bin_path, temp_dir))
210}
211
212fn create_temp_dir() -> std::io::Result<tempfile::TempDir> {
213    let mut builder = tempfile::Builder::new();
214    builder.prefix("p4cli-20251");
215    #[cfg(unix)]
216    {
217        use std::os::unix::fs::PermissionsExt;
218        builder.permissions(std::fs::Permissions::from_mode(0o700));
219    }
220    builder.tempdir()
221}
222
223fn decompress_zst(zst_data: &[u8]) -> std::io::Result<Vec<u8>> {
224    let mut decoder = zstd::stream::Decoder::new(zst_data)?;
225    let mut buf = Vec::new();
226    std::io::copy(&mut decoder, &mut buf)?;
227    Ok(buf)
228}
229
230#[cfg(unix)]
231fn set_executable_perms(path: &std::path::Path) -> std::io::Result<()> {
232    use std::os::unix::fs::PermissionsExt;
233    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
234}
235
236#[cfg(not(unix))]
237fn set_executable_perms(_path: &std::path::Path) -> std::io::Result<()> {
238    Ok(())
239}
240
241fn get_p4_cli_zst() -> Vec<u8> {
242    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
243    {
244        use p4cli_20251_win_x64::get_p4_cli_zst;
245        get_p4_cli_zst()
246    }
247
248    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
249    {
250        use p4cli_20251_mac_arm64::get_p4_cli_zst;
251        get_p4_cli_zst()
252    }
253
254    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
255    {
256        use p4cli_20251_mac_x64::get_p4_cli_zst;
257        get_p4_cli_zst()
258    }
259
260    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
261    {
262        use p4cli_20251_linux_x64::get_p4_cli_zst;
263        get_p4_cli_zst()
264    }
265
266    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
267    {
268        use p4cli_20251_linux_arm64::get_p4_cli_zst;
269        get_p4_cli_zst()
270    }
271
272    #[cfg(not(any(
273        all(target_os = "windows", target_arch = "x86_64"),
274        all(target_os = "macos", target_arch = "aarch64"),
275        all(target_os = "macos", target_arch = "x86_64"),
276        all(target_os = "linux", target_arch = "x86_64"),
277        all(target_os = "linux", target_arch = "aarch64")
278    )))]
279    {
280        compile_error!(format!(
281            "Unsupported platform: {}-{}",
282            std::env::consts::OS,
283            std::env::consts::ARCH
284        ));
285        Vec::new()
286    }
287}
288
289// ---------------------------------------------------------------------------
290// P4Command builder
291// ---------------------------------------------------------------------------
292
293/// Builder for a single p4 invocation (timeout, cwd, env, stdin).
294pub struct P4Command<'a> {
295    cli: &'a P4Cli,
296    args: Vec<std::ffi::OsString>,
297    timeout: Option<Duration>,
298    cwd: Option<PathBuf>,
299    envs: Vec<(std::ffi::OsString, std::ffi::OsString)>,
300    stdin_data: Option<Vec<u8>>,
301}
302
303impl<'a> P4Command<'a> {
304    fn new(cli: &'a P4Cli) -> Self {
305        Self {
306            cli,
307            args: Vec::new(),
308            timeout: None,
309            cwd: None,
310            envs: Vec::new(),
311            stdin_data: None,
312        }
313    }
314
315    pub fn arg(&mut self, arg: impl AsRef<std::ffi::OsStr>) -> &mut Self {
316        self.args.push(arg.as_ref().to_os_string());
317        self
318    }
319
320    pub fn args(&mut self, args: &[impl AsRef<std::ffi::OsStr>]) -> &mut Self {
321        self.args
322            .extend(args.iter().map(|a| a.as_ref().to_os_string()));
323        self
324    }
325
326    /// Maximum wall-clock time. Kills the direct child on timeout (not process tree).
327    pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
328        self.timeout = Some(timeout);
329        self
330    }
331
332    pub fn cwd(&mut self, path: impl Into<PathBuf>) -> &mut Self {
333        self.cwd = Some(path.into());
334        self
335    }
336
337    pub fn env(
338        &mut self,
339        key: impl Into<std::ffi::OsString>,
340        val: impl Into<std::ffi::OsString>,
341    ) -> &mut Self {
342        self.envs.push((key.into(), val.into()));
343        self
344    }
345
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    /// Block until the process exits, returning collected output.
352    pub fn run(&mut self) -> std::io::Result<P4Output> {
353        let mut cmd = Command::new(&self.cli.bin_path);
354        cmd.args(&self.args)
355            .stdout(Stdio::piped())
356            .stderr(Stdio::piped());
357
358        if self.stdin_data.is_some() {
359            cmd.stdin(Stdio::piped());
360        } else {
361            cmd.stdin(Stdio::null());
362        }
363
364        if let Some(ref cwd) = self.cwd {
365            cmd.current_dir(cwd);
366        }
367        for (k, v) in &self.envs {
368            cmd.env(k, v);
369        }
370
371        let mut child = cmd.spawn()?;
372
373        if let Some(data) = self.stdin_data.take()
374            && let Some(mut stdin) = child.stdin.take()
375        {
376            thread::spawn(move || {
377                let _ = stdin.write_all(&data);
378            });
379        }
380
381        let stdout = child
382            .stdout
383            .take()
384            .ok_or_else(|| std::io::Error::other("stdout was not captured"))?;
385        let stderr = child
386            .stderr
387            .take()
388            .ok_or_else(|| std::io::Error::other("stderr was not captured"))?;
389
390        let stdout_handle = thread::spawn(move || {
391            let mut buf = Vec::new();
392            BufReader::new(stdout).read_to_end(&mut buf)?;
393            Ok::<_, std::io::Error>(buf)
394        });
395
396        let stderr_handle = thread::spawn(move || {
397            let mut buf = Vec::new();
398            BufReader::new(stderr).read_to_end(&mut buf)?;
399            Ok::<_, std::io::Error>(buf)
400        });
401
402        let exit_status = wait_process(&mut child, self.timeout)?;
403
404        let stdout_buf = stdout_handle
405            .join()
406            .map_err(|_| std::io::Error::other("stdout reader thread panicked"))?
407            .map_err(|e| std::io::Error::other(format!("stdout read failed: {e}")))?;
408        let stderr_buf = stderr_handle
409            .join()
410            .map_err(|_| std::io::Error::other("stderr reader thread panicked"))?
411            .map_err(|e| std::io::Error::other(format!("stderr read failed: {e}")))?;
412
413        Ok(P4Output {
414            exit_code: exit_status.code().unwrap_or(-1),
415            stdout: stdout_buf,
416            stderr: stderr_buf,
417        })
418    }
419
420    /// Streaming iterator over stdout/stderr byte chunks.
421    ///
422    /// The final event is [`P4StreamEvent::Exit`]. Drop mid-way to cancel.
423    pub fn stream(&mut self) -> std::io::Result<P4Stream> {
424        let mut cmd = Command::new(&self.cli.bin_path);
425        cmd.args(&self.args)
426            .stdout(Stdio::piped())
427            .stderr(Stdio::piped());
428
429        if self.stdin_data.is_some() {
430            cmd.stdin(Stdio::piped());
431        } else {
432            cmd.stdin(Stdio::null());
433        }
434        if let Some(ref cwd) = self.cwd {
435            cmd.current_dir(cwd);
436        }
437        for (k, v) in &self.envs {
438            cmd.env(k, v);
439        }
440
441        let mut child = cmd.spawn()?;
442
443        if let Some(data) = self.stdin_data.take()
444            && let Some(mut stdin) = child.stdin.take()
445        {
446            thread::spawn(move || {
447                let _ = stdin.write_all(&data);
448            });
449        }
450
451        let mut stdout = child
452            .stdout
453            .take()
454            .ok_or_else(|| std::io::Error::other("stdout was not captured"))?;
455        let mut stderr = child
456            .stderr
457            .take()
458            .ok_or_else(|| std::io::Error::other("stderr was not captured"))?;
459
460        let (tx, rx) = std::sync::mpsc::channel();
461        let mut handles = Vec::new();
462
463        let tx_out = tx.clone();
464        handles.push(thread::spawn(move || {
465            let mut buf = vec![0u8; 65536];
466            loop {
467                let n = match stdout.read(&mut buf) {
468                    Ok(0) => break,
469                    Ok(n) => n,
470                    Err(e) => {
471                        let _ = tx_out.send(Err(e));
472                        break;
473                    }
474                };
475                if tx_out
476                    .send(Ok(P4StreamEvent::Stdout(buf[..n].to_vec())))
477                    .is_err()
478                {
479                    break;
480                }
481            }
482        }));
483
484        let tx_err = tx.clone();
485        handles.push(thread::spawn(move || {
486            let mut buf = vec![0u8; 65536];
487            loop {
488                let n = match stderr.read(&mut buf) {
489                    Ok(0) => break,
490                    Ok(n) => n,
491                    Err(e) => {
492                        let _ = tx_err.send(Err(e));
493                        break;
494                    }
495                };
496                if tx_err
497                    .send(Ok(P4StreamEvent::Stderr(buf[..n].to_vec())))
498                    .is_err()
499                {
500                    break;
501                }
502            }
503        }));
504
505        Ok(P4Stream {
506            rx,
507            child: Some(child),
508            handles,
509            exhausted: false,
510        })
511    }
512}
513
514// ---------------------------------------------------------------------------
515// Process helpers
516// ---------------------------------------------------------------------------
517
518fn wait_process(child: &mut Child, timeout: Option<Duration>) -> std::io::Result<ExitStatus> {
519    match timeout {
520        None => child.wait(),
521        Some(t) => wait_with_timeout(child, t),
522    }
523}
524
525fn wait_with_timeout(child: &mut Child, timeout: Duration) -> std::io::Result<ExitStatus> {
526    let start = std::time::Instant::now();
527    loop {
528        if let Some(status) = child.try_wait()? {
529            return Ok(status);
530        }
531        if start.elapsed() >= timeout {
532            child.kill()?;
533            return child.wait();
534        }
535        thread::sleep(Duration::from_millis(50));
536    }
537}
538
539// ---------------------------------------------------------------------------
540// Public API
541// ---------------------------------------------------------------------------
542
543impl P4Cli {
544    /// Decompress embedded p4 binary to a `tempfile`-managed temp directory.
545    pub fn new() -> std::io::Result<Self> {
546        let (bin_path, temp_dir) = write_p4_cli_to_disk()?;
547        Ok(Self {
548            bin_path,
549            _temp_dir: temp_dir,
550        })
551    }
552
553    /// Equivalent to `self.command().args(args).run()`.
554    pub fn run<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Output> {
555        self.command().args(args).run()
556    }
557
558    /// Equivalent to `self.command().args(args).stream()`.
559    pub fn stream<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Stream> {
560        self.command().args(args).stream()
561    }
562
563    /// Obtain a [`P4Command`] builder.
564    ///
565    /// ```rust
566    /// use p4cli_20251::P4Cli;
567    /// use std::time::Duration;
568    /// fn main() -> std::io::Result<()> {
569    ///     let p4: P4Cli = P4Cli::new()?;
570    ///     let output: p4cli_20251::P4Output = p4
571    ///         .command()
572    ///         .arg("--help")
573    ///         .timeout(Duration::from_secs(10))
574    ///         .run()?;
575    ///     if output.success() {
576    ///         println!("{}", output.stdout_str()?);
577    ///     }
578    ///     Ok(())
579    /// }
580    /// ```
581    pub fn command(&self) -> P4Command<'_> {
582        P4Command::new(self)
583    }
584}