Skip to main content

p4cli_20251/
lib.rs

1pub mod downloader;
2pub mod finder;
3pub mod platform;
4
5use std::io::{BufReader, Read, Write};
6use std::path::PathBuf;
7use std::process::{Child, Command, ExitStatus, Stdio};
8use std::thread;
9use std::time::Duration;
10
11// ---------------------------------------------------------------------------
12// P4Cli
13// ---------------------------------------------------------------------------
14
15/// A handle to a `p4` binary, either from the system or downloaded.
16///
17/// Resolution order:
18/// 1. Check `PATH` and common install directories for a working `p4`.
19/// 2. Download from Perforce's official filehost.
20///
21/// ```rust
22/// use p4cli_20251::P4Cli;
23/// fn main() -> std::io::Result<()> {
24///     let p4: P4Cli = P4Cli::new()?;
25///     let output: p4cli_20251::P4Output = p4.run(&["--help"])?;
26///     println!("exit: {}", output.exit_code());
27///     println!("stdout: {}", output.stdout_str()?);
28///     Ok(())
29/// }
30/// ```
31pub struct P4Cli {
32    bin_path: PathBuf,
33    /// Held only when p4 was downloaded (auto-cleaned on Drop).
34    _cache: Option<tempfile::TempDir>,
35}
36
37impl P4Cli {
38    /// Locate or download a working `p4` binary.
39    pub fn new() -> std::io::Result<Self> {
40        // 1. Try system.
41        if let Some(path) = finder::find_system_p4() {
42            return Ok(Self {
43                bin_path: path,
44                _cache: None,
45            });
46        }
47        // 2. Download from Perforce.
48        let (bin_path, cache) = downloader::download_p4()?;
49        Ok(Self {
50            bin_path,
51            _cache: Some(cache),
52        })
53    }
54
55    /// Equivalent to `self.command().args(args).run()`.
56    pub fn run<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Output> {
57        self.command().args(args).run()
58    }
59
60    /// Equivalent to `self.command().args(args).stream()`.
61    pub fn stream<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Stream> {
62        self.command().args(args).stream()
63    }
64
65    /// Obtain a [`P4Command`] builder.
66    ///
67    /// ```rust
68    /// use p4cli_20251::P4Cli;
69    /// use std::time::Duration;
70    /// fn main() -> std::io::Result<()> {
71    ///     let p4: P4Cli = P4Cli::new()?;
72    ///     let output: p4cli_20251::P4Output = p4
73    ///         .command()
74    ///         .arg("--help")
75    ///         .timeout(Duration::from_secs(10))
76    ///         .run()?;
77    ///     if output.success() {
78    ///         println!("{}", output.stdout_str()?);
79    ///     }
80    ///     Ok(())
81    /// }
82    /// ```
83    pub fn command(&self) -> P4Command<'_> {
84        P4Command::new(self)
85    }
86}
87
88// ---------------------------------------------------------------------------
89// P4Output
90// ---------------------------------------------------------------------------
91
92/// Raw stdout/stderr bytes, exit code and timeout flag from a p4 invocation.
93pub struct P4Output {
94    exit_code: i32,
95    timed_out: bool,
96    stdout: Vec<u8>,
97    stderr: Vec<u8>,
98}
99
100impl P4Output {
101    pub fn exit_code(&self) -> i32 {
102        self.exit_code
103    }
104
105    pub fn timed_out(&self) -> bool {
106        self.timed_out
107    }
108
109    pub fn success(&self) -> bool {
110        !self.timed_out && self.exit_code == 0
111    }
112
113    pub fn stdout(&self) -> &[u8] {
114        &self.stdout
115    }
116
117    pub fn stderr(&self) -> &[u8] {
118        &self.stderr
119    }
120
121    pub fn stdout_str(&self) -> std::io::Result<&str> {
122        std::str::from_utf8(&self.stdout).map_err(std::io::Error::other)
123    }
124
125    pub fn stderr_str(&self) -> std::io::Result<&str> {
126        std::str::from_utf8(&self.stderr).map_err(std::io::Error::other)
127    }
128
129    pub fn stdout_lines(&self) -> std::io::Result<Vec<&str>> {
130        let s = self.stdout_str()?;
131        if s.is_empty() {
132            Ok(Vec::new())
133        } else {
134            Ok(s.lines().collect())
135        }
136    }
137
138    pub fn stderr_lines(&self) -> std::io::Result<Vec<&str>> {
139        let s = self.stderr_str()?;
140        if s.is_empty() {
141            Ok(Vec::new())
142        } else {
143            Ok(s.lines().collect())
144        }
145    }
146}
147
148impl std::fmt::Debug for P4Output {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("P4Output")
151            .field("exit_code", &self.exit_code)
152            .field("timed_out", &self.timed_out)
153            .field("stdout_len", &self.stdout.len())
154            .field("stderr_len", &self.stderr.len())
155            .finish()
156    }
157}
158
159// ---------------------------------------------------------------------------
160// Streaming API
161// ---------------------------------------------------------------------------
162
163pub enum P4StreamEvent {
164    Stdout(Vec<u8>),
165    Stderr(Vec<u8>),
166    Exit(i32),
167}
168
169impl P4StreamEvent {
170    pub fn as_utf8(&self) -> Option<&str> {
171        match self {
172            P4StreamEvent::Stdout(data) | P4StreamEvent::Stderr(data) => {
173                std::str::from_utf8(data).ok()
174            }
175            P4StreamEvent::Exit(_) => None,
176        }
177    }
178}
179
180impl std::fmt::Display for P4StreamEvent {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        match self {
183            P4StreamEvent::Stdout(data) | P4StreamEvent::Stderr(data) => {
184                if let Ok(text) = std::str::from_utf8(data) {
185                    write!(f, "{text}")
186                } else {
187                    write!(f, "<{} bytes>", data.len())
188                }
189            }
190            P4StreamEvent::Exit(code) => write!(f, "(exit {code})"),
191        }
192    }
193}
194
195/// Merged stdout/stderr byte chunks (~64 KB each) via bounded channel (64 slots).
196///
197/// ```rust
198/// use p4cli_20251::{P4Cli, P4StreamEvent};
199/// fn main() -> std::io::Result<()> {
200///     let p4: P4Cli = P4Cli::new()?;
201///     for event in p4.stream(&["--help"])? {
202///         match event? {
203///             P4StreamEvent::Stdout(chunk) => {
204///                 if let Ok(text) = std::str::from_utf8(&chunk) {
205///                     print!("{text}");
206///                 }
207///             }
208///             P4StreamEvent::Stderr(chunk) => {
209///                 if let Ok(text) = std::str::from_utf8(&chunk) {
210///                     eprint!("{text}");
211///                 }
212///             }
213///             P4StreamEvent::Exit(code) => println!("exit {code}"),
214///         }
215///     }
216///     Ok(())
217/// }
218/// ```
219pub struct P4Stream {
220    rx: std::sync::mpsc::Receiver<std::io::Result<P4StreamEvent>>,
221    child: Option<Child>,
222    exhausted: bool,
223}
224
225impl std::fmt::Debug for P4Stream {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        f.debug_struct("P4Stream")
228            .field("exhausted", &self.exhausted)
229            .finish()
230    }
231}
232
233impl Iterator for P4Stream {
234    type Item = std::io::Result<P4StreamEvent>;
235
236    fn next(&mut self) -> Option<Self::Item> {
237        if self.exhausted {
238            return None;
239        }
240        match self.rx.recv() {
241            Ok(item) => Some(item),
242            Err(_) => {
243                self.exhausted = true;
244                match self.child.take() {
245                    Some(mut c) => match c.wait() {
246                        Ok(status) => Some(Ok(P4StreamEvent::Exit(status.code().unwrap_or(-1)))),
247                        Err(e) => Some(Err(e)),
248                    },
249                    None => None,
250                }
251            }
252        }
253    }
254}
255
256impl Drop for P4Stream {
257    fn drop(&mut self) {
258        if let Some(ref mut child) = self.child {
259            let _ = child.kill();
260            let _ = child.wait();
261        }
262    }
263}
264
265// ---------------------------------------------------------------------------
266// P4Command builder
267// ---------------------------------------------------------------------------
268
269pub struct P4Command<'a> {
270    cli: &'a P4Cli,
271    args: Vec<std::ffi::OsString>,
272    timeout: Option<Duration>,
273    cwd: Option<PathBuf>,
274    envs: Vec<(std::ffi::OsString, std::ffi::OsString)>,
275    stdin_data: Option<Vec<u8>>,
276}
277
278impl<'a> P4Command<'a> {
279    fn new(cli: &'a P4Cli) -> Self {
280        Self {
281            cli,
282            args: Vec::new(),
283            timeout: None,
284            cwd: None,
285            envs: Vec::new(),
286            stdin_data: None,
287        }
288    }
289
290    pub fn arg(&mut self, arg: impl AsRef<std::ffi::OsStr>) -> &mut Self {
291        self.args.push(arg.as_ref().to_os_string());
292        self
293    }
294
295    pub fn args(&mut self, args: &[impl AsRef<std::ffi::OsStr>]) -> &mut Self {
296        self.args
297            .extend(args.iter().map(|a| a.as_ref().to_os_string()));
298        self
299    }
300
301    /// Maximum wall-clock time. Kills the direct child on timeout.
302    /// Use [`P4Output::timed_out`] to detect timeout vs normal exit.
303    pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
304        self.timeout = Some(timeout);
305        self
306    }
307
308    pub fn cwd(&mut self, path: impl Into<PathBuf>) -> &mut Self {
309        self.cwd = Some(path.into());
310        self
311    }
312
313    pub fn env(
314        &mut self,
315        key: impl Into<std::ffi::OsString>,
316        val: impl Into<std::ffi::OsString>,
317    ) -> &mut Self {
318        self.envs.push((key.into(), val.into()));
319        self
320    }
321
322    pub fn stdin(&mut self, data: impl Into<Vec<u8>>) -> &mut Self {
323        self.stdin_data = Some(data.into());
324        self
325    }
326
327    pub fn run(&mut self) -> std::io::Result<P4Output> {
328        let mut cmd = Command::new(&self.cli.bin_path);
329        cmd.args(&self.args)
330            .stdout(Stdio::piped())
331            .stderr(Stdio::piped());
332        if self.stdin_data.is_some() {
333            cmd.stdin(Stdio::piped());
334        } else {
335            cmd.stdin(Stdio::null());
336        }
337        if let Some(ref cwd) = self.cwd {
338            cmd.current_dir(cwd);
339        }
340        for (k, v) in &self.envs {
341            cmd.env(k, v);
342        }
343
344        let mut child = cmd.spawn()?;
345
346        let stdin_handle = self.stdin_data.take().and_then(|data| {
347            child.stdin.take().map(|mut stdin| {
348                thread::spawn(move || match stdin.write_all(&data) {
349                    Err(e) => Err(e),
350                    Ok(_) => Ok(()),
351                })
352            })
353        });
354
355        let stdout = child
356            .stdout
357            .take()
358            .ok_or_else(|| std::io::Error::other("stdout was not captured"))?;
359        let stderr = child
360            .stderr
361            .take()
362            .ok_or_else(|| std::io::Error::other("stderr was not captured"))?;
363
364        let stdout_handle = thread::spawn(move || {
365            let mut buf = Vec::new();
366            BufReader::new(stdout).read_to_end(&mut buf)?;
367            Ok::<_, std::io::Error>(buf)
368        });
369        let stderr_handle = thread::spawn(move || {
370            let mut buf = Vec::new();
371            BufReader::new(stderr).read_to_end(&mut buf)?;
372            Ok::<_, std::io::Error>(buf)
373        });
374
375        let (exit_status, timed_out) = wait_process(&mut child, self.timeout)?;
376
377        if let Some(handle) = stdin_handle {
378            handle
379                .join()
380                .map_err(|_| std::io::Error::other("stdin thread panicked"))?
381                .map_err(|e| std::io::Error::other(format!("stdin write failed: {e}")))?;
382        }
383        let stdout_buf = stdout_handle
384            .join()
385            .map_err(|_| std::io::Error::other("stdout reader thread panicked"))?
386            .map_err(|e| std::io::Error::other(format!("stdout read failed: {e}")))?;
387        let stderr_buf = stderr_handle
388            .join()
389            .map_err(|_| std::io::Error::other("stderr reader thread panicked"))?
390            .map_err(|e| std::io::Error::other(format!("stderr read failed: {e}")))?;
391
392        Ok(P4Output {
393            exit_code: exit_status.code().unwrap_or(-1),
394            timed_out,
395            stdout: stdout_buf,
396            stderr: stderr_buf,
397        })
398    }
399
400    /// Streaming iterator over stdout/stderr byte chunks.
401    /// Uses a bounded channel (64 slots). Timeout is not supported here.
402    pub fn stream(&mut self) -> std::io::Result<P4Stream> {
403        if self.timeout.is_some() {
404            return Err(std::io::Error::other(
405                "timeout is not supported on stream(); use run() instead",
406            ));
407        }
408        let mut cmd = Command::new(&self.cli.bin_path);
409        cmd.args(&self.args)
410            .stdout(Stdio::piped())
411            .stderr(Stdio::piped());
412        if self.stdin_data.is_some() {
413            cmd.stdin(Stdio::piped());
414        } else {
415            cmd.stdin(Stdio::null());
416        }
417        if let Some(ref cwd) = self.cwd {
418            cmd.current_dir(cwd);
419        }
420        for (k, v) in &self.envs {
421            cmd.env(k, v);
422        }
423
424        let mut child = cmd.spawn()?;
425        if let Some(data) = self.stdin_data.take()
426            && let Some(mut stdin) = child.stdin.take()
427        {
428            thread::spawn(move || {
429                let _ = stdin.write_all(&data);
430            });
431        }
432
433        let mut stdout = child
434            .stdout
435            .take()
436            .ok_or_else(|| std::io::Error::other("stdout was not captured"))?;
437        let mut stderr = child
438            .stderr
439            .take()
440            .ok_or_else(|| std::io::Error::other("stderr was not captured"))?;
441
442        let (tx, rx) = std::sync::mpsc::sync_channel(64);
443
444        let tx_out = tx.clone();
445        thread::spawn(move || {
446            let mut buf = vec![0u8; 65536];
447            loop {
448                let n = match stdout.read(&mut buf) {
449                    Ok(0) => break,
450                    Ok(n) => n,
451                    Err(e) => {
452                        let _ = tx_out.send(Err(e));
453                        break;
454                    }
455                };
456                if tx_out
457                    .send(Ok(P4StreamEvent::Stdout(buf[..n].to_vec())))
458                    .is_err()
459                {
460                    break;
461                }
462            }
463        });
464
465        let tx_err = tx.clone();
466        thread::spawn(move || {
467            let mut buf = vec![0u8; 65536];
468            loop {
469                let n = match stderr.read(&mut buf) {
470                    Ok(0) => break,
471                    Ok(n) => n,
472                    Err(e) => {
473                        let _ = tx_err.send(Err(e));
474                        break;
475                    }
476                };
477                if tx_err
478                    .send(Ok(P4StreamEvent::Stderr(buf[..n].to_vec())))
479                    .is_err()
480                {
481                    break;
482                }
483            }
484        });
485
486        Ok(P4Stream {
487            rx,
488            child: Some(child),
489            exhausted: false,
490        })
491    }
492}
493
494// ---------------------------------------------------------------------------
495// Process helpers
496// ---------------------------------------------------------------------------
497
498fn wait_process(
499    child: &mut Child,
500    timeout: Option<Duration>,
501) -> std::io::Result<(ExitStatus, bool)> {
502    match timeout {
503        None => Ok((child.wait()?, false)),
504        Some(t) => wait_with_timeout(child, t),
505    }
506}
507
508fn wait_with_timeout(child: &mut Child, timeout: Duration) -> std::io::Result<(ExitStatus, bool)> {
509    let start = std::time::Instant::now();
510    loop {
511        if let Some(status) = child.try_wait()? {
512            return Ok((status, false));
513        }
514        if start.elapsed() >= timeout {
515            child.kill()?;
516            return Ok((child.wait()?, true));
517        }
518        thread::sleep(Duration::from_millis(50));
519    }
520}