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, 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: PathBuf,
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, PathBuf)> {
194    let zst_data = get_p4_cli_zst();
195    let binary_data = decompress_zst(&zst_data)?;
196
197    let base = std::env::temp_dir().join("p4cli-20251");
198    std::fs::create_dir_all(&base)?;
199
200    let dir = base.join(format!(
201        "{}_{}",
202        std::process::id(),
203        std::time::SystemTime::now()
204            .duration_since(std::time::UNIX_EPOCH)
205            .map(|d| d.as_nanos())
206            .unwrap_or(0)
207    ));
208    std::fs::create_dir(&dir)?;
209
210    let bin_path = dir.join("p4_binary");
211    let tmp_path = dir.join(".tmp");
212
213    {
214        let mut file = std::fs::File::create(&tmp_path)?;
215        file.write_all(&binary_data)?;
216        file.sync_all()?;
217    }
218    std::fs::rename(&tmp_path, &bin_path)?;
219    set_executable_perms(&bin_path)?;
220
221    Ok((bin_path, dir))
222}
223
224fn decompress_zst(zst_data: &[u8]) -> std::io::Result<Vec<u8>> {
225    let mut decoder = zstd::stream::Decoder::new(zst_data)?;
226    let mut buf = Vec::new();
227    std::io::copy(&mut decoder, &mut buf)?;
228    Ok(buf)
229}
230
231#[cfg(unix)]
232fn set_executable_perms(path: &std::path::Path) -> std::io::Result<()> {
233    use std::os::unix::fs::PermissionsExt;
234    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
235}
236
237#[cfg(not(unix))]
238fn set_executable_perms(_path: &std::path::Path) -> std::io::Result<()> {
239    Ok(())
240}
241
242fn get_p4_cli_zst() -> Vec<u8> {
243    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
244    {
245        use p4cli_20251_win_x64::get_p4_cli_zst;
246        get_p4_cli_zst()
247    }
248
249    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
250    {
251        use p4cli_20251_mac_arm64::get_p4_cli_zst;
252        get_p4_cli_zst()
253    }
254
255    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
256    {
257        use p4cli_20251_mac_x64::get_p4_cli_zst;
258        get_p4_cli_zst()
259    }
260
261    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
262    {
263        use p4cli_20251_linux_x64::get_p4_cli_zst;
264        get_p4_cli_zst()
265    }
266
267    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
268    {
269        use p4cli_20251_linux_arm64::get_p4_cli_zst;
270        get_p4_cli_zst()
271    }
272
273    #[cfg(not(any(
274        all(target_os = "windows", target_arch = "x86_64"),
275        all(target_os = "macos", target_arch = "aarch64"),
276        all(target_os = "macos", target_arch = "x86_64"),
277        all(target_os = "linux", target_arch = "x86_64"),
278        all(target_os = "linux", target_arch = "aarch64")
279    )))]
280    {
281        compile_error!(format!(
282            "Unsupported platform: {}-{}",
283            std::env::consts::OS,
284            std::env::consts::ARCH
285        ));
286        Vec::new()
287    }
288}
289
290// ---------------------------------------------------------------------------
291// P4Command builder
292// ---------------------------------------------------------------------------
293
294/// Builder for a single p4 invocation (timeout, cwd, env, stdin).
295pub struct P4Command<'a> {
296    cli: &'a P4Cli,
297    args: Vec<std::ffi::OsString>,
298    timeout: Option<Duration>,
299    cwd: Option<PathBuf>,
300    envs: Vec<(std::ffi::OsString, std::ffi::OsString)>,
301    stdin_data: Option<Vec<u8>>,
302}
303
304impl<'a> P4Command<'a> {
305    fn new(cli: &'a P4Cli) -> Self {
306        Self {
307            cli,
308            args: Vec::new(),
309            timeout: None,
310            cwd: None,
311            envs: Vec::new(),
312            stdin_data: None,
313        }
314    }
315
316    pub fn arg(&mut self, arg: impl AsRef<std::ffi::OsStr>) -> &mut Self {
317        self.args.push(arg.as_ref().to_os_string());
318        self
319    }
320
321    pub fn args(&mut self, args: &[impl AsRef<std::ffi::OsStr>]) -> &mut Self {
322        self.args
323            .extend(args.iter().map(|a| a.as_ref().to_os_string()));
324        self
325    }
326
327    /// Maximum wall-clock time. Kills the direct child on timeout (not process tree).
328    pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
329        self.timeout = Some(timeout);
330        self
331    }
332
333    pub fn cwd(&mut self, path: impl Into<PathBuf>) -> &mut Self {
334        self.cwd = Some(path.into());
335        self
336    }
337
338    pub fn env(
339        &mut self,
340        key: impl Into<std::ffi::OsString>,
341        val: impl Into<std::ffi::OsString>,
342    ) -> &mut Self {
343        self.envs.push((key.into(), val.into()));
344        self
345    }
346
347    pub fn stdin(&mut self, data: impl Into<Vec<u8>>) -> &mut Self {
348        self.stdin_data = Some(data.into());
349        self
350    }
351
352    /// Block until the process exits, returning collected output.
353    pub fn run(&mut self) -> std::io::Result<P4Output> {
354        let mut cmd = Command::new(&self.cli.bin_path);
355        cmd.args(&self.args)
356            .stdout(Stdio::piped())
357            .stderr(Stdio::piped());
358
359        if self.stdin_data.is_some() {
360            cmd.stdin(Stdio::piped());
361        } else {
362            cmd.stdin(Stdio::null());
363        }
364
365        if let Some(ref cwd) = self.cwd {
366            cmd.current_dir(cwd);
367        }
368        for (k, v) in &self.envs {
369            cmd.env(k, v);
370        }
371
372        let mut child = cmd.spawn()?;
373
374        if let Some(data) = self.stdin_data.take()
375            && let Some(mut stdin) = child.stdin.take()
376        {
377            thread::spawn(move || {
378                let _ = stdin.write_all(&data);
379            });
380        }
381
382        let stdout = child
383            .stdout
384            .take()
385            .ok_or_else(|| std::io::Error::other("stdout was not captured"))?;
386        let stderr = child
387            .stderr
388            .take()
389            .ok_or_else(|| std::io::Error::other("stderr was not captured"))?;
390
391        let stdout_handle = thread::spawn(move || {
392            let mut buf = Vec::new();
393            BufReader::new(stdout).read_to_end(&mut buf)?;
394            Ok::<_, std::io::Error>(buf)
395        });
396
397        let stderr_handle = thread::spawn(move || {
398            let mut buf = Vec::new();
399            BufReader::new(stderr).read_to_end(&mut buf)?;
400            Ok::<_, std::io::Error>(buf)
401        });
402
403        let exit_status = wait_process(&mut child, self.timeout)?;
404
405        let stdout_buf = stdout_handle
406            .join()
407            .map_err(|_| std::io::Error::other("stdout reader thread panicked"))?
408            .map_err(|e| std::io::Error::other(format!("stdout read failed: {e}")))?;
409        let stderr_buf = stderr_handle
410            .join()
411            .map_err(|_| std::io::Error::other("stderr reader thread panicked"))?
412            .map_err(|e| std::io::Error::other(format!("stderr read failed: {e}")))?;
413
414        Ok(P4Output {
415            exit_code: exit_status.code().unwrap_or(-1),
416            stdout: stdout_buf,
417            stderr: stderr_buf,
418        })
419    }
420
421    /// Streaming iterator over stdout/stderr byte chunks.
422    ///
423    /// The final event is [`P4StreamEvent::Exit`]. Drop mid-way to cancel.
424    pub fn stream(&mut self) -> std::io::Result<P4Stream> {
425        let mut cmd = Command::new(&self.cli.bin_path);
426        cmd.args(&self.args)
427            .stdout(Stdio::piped())
428            .stderr(Stdio::piped());
429
430        if self.stdin_data.is_some() {
431            cmd.stdin(Stdio::piped());
432        } else {
433            cmd.stdin(Stdio::null());
434        }
435        if let Some(ref cwd) = self.cwd {
436            cmd.current_dir(cwd);
437        }
438        for (k, v) in &self.envs {
439            cmd.env(k, v);
440        }
441
442        let mut child = cmd.spawn()?;
443
444        if let Some(data) = self.stdin_data.take()
445            && let Some(mut stdin) = child.stdin.take()
446        {
447            thread::spawn(move || {
448                let _ = stdin.write_all(&data);
449            });
450        }
451
452        let mut stdout = child
453            .stdout
454            .take()
455            .ok_or_else(|| std::io::Error::other("stdout was not captured"))?;
456        let mut stderr = child
457            .stderr
458            .take()
459            .ok_or_else(|| std::io::Error::other("stderr was not captured"))?;
460
461        let (tx, rx) = std::sync::mpsc::channel();
462        let mut handles = Vec::new();
463
464        let tx_out = tx.clone();
465        handles.push(thread::spawn(move || {
466            let mut buf = vec![0u8; 65536];
467            loop {
468                let n = match stdout.read(&mut buf) {
469                    Ok(0) => break,
470                    Ok(n) => n,
471                    Err(e) => {
472                        let _ = tx_out.send(Err(e));
473                        break;
474                    }
475                };
476                if tx_out
477                    .send(Ok(P4StreamEvent::Stdout(buf[..n].to_vec())))
478                    .is_err()
479                {
480                    break;
481                }
482            }
483        }));
484
485        let tx_err = tx.clone();
486        handles.push(thread::spawn(move || {
487            let mut buf = vec![0u8; 65536];
488            loop {
489                let n = match stderr.read(&mut buf) {
490                    Ok(0) => break,
491                    Ok(n) => n,
492                    Err(e) => {
493                        let _ = tx_err.send(Err(e));
494                        break;
495                    }
496                };
497                if tx_err
498                    .send(Ok(P4StreamEvent::Stderr(buf[..n].to_vec())))
499                    .is_err()
500                {
501                    break;
502                }
503            }
504        }));
505
506        Ok(P4Stream {
507            rx,
508            child: Some(child),
509            handles,
510            exhausted: false,
511        })
512    }
513}
514
515// ---------------------------------------------------------------------------
516// Process helpers
517// ---------------------------------------------------------------------------
518
519fn wait_process(child: &mut Child, timeout: Option<Duration>) -> std::io::Result<ExitStatus> {
520    match timeout {
521        None => child.wait(),
522        Some(t) => wait_with_timeout(child, t),
523    }
524}
525
526fn wait_with_timeout(child: &mut Child, timeout: Duration) -> std::io::Result<ExitStatus> {
527    let start = std::time::Instant::now();
528    loop {
529        if let Some(status) = child.try_wait()? {
530            return Ok(status);
531        }
532        if start.elapsed() >= timeout {
533            child.kill()?;
534            return child.wait();
535        }
536        thread::sleep(Duration::from_millis(50));
537    }
538}
539
540// ---------------------------------------------------------------------------
541// Public API
542// ---------------------------------------------------------------------------
543
544impl P4Cli {
545    /// Decompress embedded p4 binary to a fresh temp directory.
546    pub fn new() -> std::io::Result<Self> {
547        let (bin_path, temp_dir) = write_p4_cli_to_disk()?;
548        Ok(Self {
549            bin_path,
550            _temp_dir: temp_dir,
551        })
552    }
553
554    /// Equivalent to `self.command().args(args).run()`.
555    pub fn run<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Output> {
556        self.command().args(args).run()
557    }
558
559    /// Equivalent to `self.command().args(args).stream()`.
560    pub fn stream<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Stream> {
561        self.command().args(args).stream()
562    }
563
564    /// Obtain a [`P4Command`] builder.
565    ///
566    /// ```rust
567    /// use p4cli_20251::P4Cli;
568    /// use std::time::Duration;
569    /// fn main() -> std::io::Result<()> {
570    ///     let p4: P4Cli = P4Cli::new()?;
571    ///     let output: p4cli_20251::P4Output = p4
572    ///         .command()
573    ///         .arg("--help")
574    ///         .timeout(Duration::from_secs(10))
575    ///         .run()?;
576    ///     if output.success() {
577    ///         println!("{}", output.stdout_str()?);
578    ///     }
579    ///     Ok(())
580    /// }
581    /// ```
582    pub fn command(&self) -> P4Command<'_> {
583        P4Command::new(self)
584    }
585}
586
587impl Drop for P4Cli {
588    fn drop(&mut self) {
589        let _ = std::fs::remove_dir_all(&self._temp_dir);
590    }
591}