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
7pub struct P4Cli {
12 bin_path: PathBuf,
13 _temp_dir: PathBuf,
14}
15
16pub 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 pub fn success(&self) -> bool {
33 self.exit_code == 0
34 }
35
36 pub fn stdout(&self) -> &[u8] {
38 &self.stdout
39 }
40
41 pub fn stderr(&self) -> &[u8] {
43 &self.stderr
44 }
45
46 pub fn stdout_str(&self) -> Result<&str, std::str::Utf8Error> {
48 std::str::from_utf8(&self.stdout)
49 }
50
51 pub fn stderr_str(&self) -> Result<&str, std::str::Utf8Error> {
53 std::str::from_utf8(&self.stderr)
54 }
55
56 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 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
87pub enum P4StreamEvent {
93 Stdout(Vec<u8>),
95 Stderr(Vec<u8>),
97 Exit(i32),
99}
100
101impl P4StreamEvent {
102 pub fn as_utf8(&self) -> Option<&str> {
105 match self {
106 P4StreamEvent::Stdout(data) | P4StreamEvent::Stderr(data) => {
107 std::str::from_utf8(data).ok()
108 }
109 P4StreamEvent::Exit(_) => None,
110 }
111 }
112}
113
114impl std::fmt::Display for P4StreamEvent {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 P4StreamEvent::Stdout(data) | P4StreamEvent::Stderr(data) => {
118 if let Ok(text) = std::str::from_utf8(data) {
119 write!(f, "{text}")
120 } else {
121 write!(f, "<{} bytes>", data.len())
122 }
123 }
124 P4StreamEvent::Exit(code) => write!(f, "(exit {code})"),
125 }
126 }
127}
128
129pub struct P4Stream {
145 rx: std::sync::mpsc::Receiver<std::io::Result<P4StreamEvent>>,
146 child: Option<Child>,
147 #[allow(dead_code)]
150 handles: Vec<thread::JoinHandle<()>>,
151 exhausted: bool,
152}
153
154impl Iterator for P4Stream {
155 type Item = std::io::Result<P4StreamEvent>;
156
157 fn next(&mut self) -> Option<Self::Item> {
158 if self.exhausted {
159 return None;
160 }
161 match self.rx.recv() {
162 Ok(item) => Some(item),
163 Err(_) => {
164 self.exhausted = true;
166 let code = self
167 .child
168 .take()
169 .and_then(|mut c| c.wait().ok())
170 .and_then(|s| s.code())
171 .unwrap_or(-1);
172 Some(Ok(P4StreamEvent::Exit(code)))
173 }
174 }
175 }
176}
177
178impl Drop for P4Stream {
179 fn drop(&mut self) {
180 if let Some(ref mut child) = self.child {
182 let _ = child.kill();
183 let _ = child.wait();
184 }
185 }
187}
188
189fn write_p4_cli_to_disk() -> std::io::Result<(PathBuf, PathBuf)> {
196 let zst_data = get_p4_cli_zst();
197 let binary_data = decompress_zst(&zst_data)?;
198
199 let base = std::env::temp_dir().join("p4cli-20251");
200 std::fs::create_dir_all(&base)?;
201
202 let dir = base.join(format!(
203 "{}_{}",
204 std::process::id(),
205 std::time::SystemTime::now()
206 .duration_since(std::time::UNIX_EPOCH)
207 .map(|d| d.as_nanos())
208 .unwrap_or(0)
209 ));
210 std::fs::create_dir(&dir)?;
211
212 let bin_path = dir.join("p4_binary");
213 let tmp_path = dir.join(".tmp");
214
215 {
217 let mut file = std::fs::File::create(&tmp_path)?;
218 file.write_all(&binary_data)?;
219 file.sync_all()?;
220 }
221 std::fs::rename(&tmp_path, &bin_path)?;
222 set_executable_perms(&bin_path)?;
223
224 Ok((bin_path, dir))
225}
226
227fn decompress_zst(zst_data: &[u8]) -> std::io::Result<Vec<u8>> {
228 let mut decoder = zstd::stream::Decoder::new(zst_data)?;
229 let mut buf = Vec::new();
230 std::io::copy(&mut decoder, &mut buf)?;
231 Ok(buf)
232}
233
234#[cfg(unix)]
235fn set_executable_perms(path: &std::path::Path) -> std::io::Result<()> {
236 use std::os::unix::fs::PermissionsExt;
237 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
238}
239
240#[cfg(not(unix))]
241fn set_executable_perms(_path: &std::path::Path) -> std::io::Result<()> {
242 Ok(())
243}
244
245fn get_p4_cli_zst() -> Vec<u8> {
250 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
251 {
252 use p4cli_20251_win_x64::get_p4_cli_zst;
253 get_p4_cli_zst()
254 }
255
256 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
257 {
258 use p4cli_20251_mac_arm64::get_p4_cli_zst;
259 get_p4_cli_zst()
260 }
261
262 #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
263 {
264 use p4cli_20251_mac_x64::get_p4_cli_zst;
265 get_p4_cli_zst()
266 }
267
268 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
269 {
270 use p4cli_20251_linux_x64::get_p4_cli_zst;
271 get_p4_cli_zst()
272 }
273
274 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
275 {
276 use p4cli_20251_linux_arm64::get_p4_cli_zst;
277 get_p4_cli_zst()
278 }
279
280 #[cfg(not(any(
281 all(target_os = "windows", target_arch = "x86_64"),
282 all(target_os = "macos", target_arch = "aarch64"),
283 all(target_os = "macos", target_arch = "x86_64"),
284 all(target_os = "linux", target_arch = "x86_64"),
285 all(target_os = "linux", target_arch = "aarch64")
286 )))]
287 {
288 compile_error!(format!(
289 "Unsupported platform: {}-{}",
290 std::env::consts::OS,
291 std::env::consts::ARCH
292 ));
293 Vec::new()
294 }
295}
296
297pub struct P4Command<'a> {
306 cli: &'a P4Cli,
307 args: Vec<std::ffi::OsString>,
308 timeout: Option<Duration>,
309 cwd: Option<PathBuf>,
310 envs: Vec<(std::ffi::OsString, std::ffi::OsString)>,
311 stdin_data: Option<Vec<u8>>,
312}
313
314impl<'a> P4Command<'a> {
315 fn new(cli: &'a P4Cli) -> Self {
316 Self {
317 cli,
318 args: Vec::new(),
319 timeout: None,
320 cwd: None,
321 envs: Vec::new(),
322 stdin_data: None,
323 }
324 }
325
326 pub fn arg(&mut self, arg: impl AsRef<std::ffi::OsStr>) -> &mut Self {
328 self.args.push(arg.as_ref().to_os_string());
329 self
330 }
331
332 pub fn args(&mut self, args: &[impl AsRef<std::ffi::OsStr>]) -> &mut Self {
334 self.args
335 .extend(args.iter().map(|a| a.as_ref().to_os_string()));
336 self
337 }
338
339 pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
345 self.timeout = Some(timeout);
346 self
347 }
348
349 pub fn cwd(&mut self, path: impl Into<PathBuf>) -> &mut Self {
351 self.cwd = Some(path.into());
352 self
353 }
354
355 pub fn env(
357 &mut self,
358 key: impl Into<std::ffi::OsString>,
359 val: impl Into<std::ffi::OsString>,
360 ) -> &mut Self {
361 self.envs.push((key.into(), val.into()));
362 self
363 }
364
365 pub fn stdin(&mut self, data: impl Into<Vec<u8>>) -> &mut Self {
367 self.stdin_data = Some(data.into());
368 self
369 }
370
371 pub fn run(&mut self) -> std::io::Result<P4Output> {
377 let mut cmd = Command::new(&self.cli.bin_path);
378 cmd.args(&self.args)
379 .stdout(Stdio::piped())
380 .stderr(Stdio::piped());
381
382 if self.stdin_data.is_some() {
383 cmd.stdin(Stdio::piped());
384 } else {
385 cmd.stdin(Stdio::null());
386 }
387
388 if let Some(ref cwd) = self.cwd {
389 cmd.current_dir(cwd);
390 }
391 for (k, v) in &self.envs {
392 cmd.env(k, v);
393 }
394
395 let mut child = cmd.spawn()?;
396
397 if let Some(data) = self.stdin_data.take()
399 && let Some(mut stdin) = child.stdin.take()
400 {
401 thread::spawn(move || {
402 let _ = stdin.write_all(&data);
403 });
404 }
405
406 let stdout = child
407 .stdout
408 .take()
409 .ok_or_else(|| std::io::Error::other("stdout was not captured"))?;
410 let stderr = child
411 .stderr
412 .take()
413 .ok_or_else(|| std::io::Error::other("stderr was not captured"))?;
414
415 let stdout_handle = thread::spawn(move || {
417 let mut buf = Vec::new();
418 BufReader::new(stdout).read_to_end(&mut buf)?;
419 Ok::<_, std::io::Error>(buf)
420 });
421
422 let stderr_handle = thread::spawn(move || {
423 let mut buf = Vec::new();
424 BufReader::new(stderr).read_to_end(&mut buf)?;
425 Ok::<_, std::io::Error>(buf)
426 });
427
428 let exit_status = wait_process(&mut child, self.timeout)?;
430
431 let stdout_buf = stdout_handle
432 .join()
433 .map_err(|_| std::io::Error::other("stdout reader thread panicked"))?
434 .map_err(|e| std::io::Error::other(format!("stdout read failed: {e}")))?;
435 let stderr_buf = stderr_handle
436 .join()
437 .map_err(|_| std::io::Error::other("stderr reader thread panicked"))?
438 .map_err(|e| std::io::Error::other(format!("stderr read failed: {e}")))?;
439
440 Ok(P4Output {
441 exit_code: exit_status.code().unwrap_or(-1),
442 stdout: stdout_buf,
443 stderr: stderr_buf,
444 })
445 }
446
447 pub fn stream(&mut self) -> std::io::Result<P4Stream> {
458 let mut cmd = Command::new(&self.cli.bin_path);
459 cmd.args(&self.args)
460 .stdout(Stdio::piped())
461 .stderr(Stdio::piped());
462
463 if self.stdin_data.is_some() {
464 cmd.stdin(Stdio::piped());
465 } else {
466 cmd.stdin(Stdio::null());
467 }
468 if let Some(ref cwd) = self.cwd {
469 cmd.current_dir(cwd);
470 }
471 for (k, v) in &self.envs {
472 cmd.env(k, v);
473 }
474
475 let mut child = cmd.spawn()?;
476
477 if let Some(data) = self.stdin_data.take()
478 && let Some(mut stdin) = child.stdin.take()
479 {
480 thread::spawn(move || {
481 let _ = stdin.write_all(&data);
482 });
483 }
484
485 let mut stdout = child
486 .stdout
487 .take()
488 .ok_or_else(|| std::io::Error::other("stdout was not captured"))?;
489 let mut stderr = child
490 .stderr
491 .take()
492 .ok_or_else(|| std::io::Error::other("stderr was not captured"))?;
493
494 let (tx, rx) = std::sync::mpsc::channel();
495 let mut handles = Vec::new();
496
497 let tx_out = tx.clone();
499 handles.push(thread::spawn(move || {
500 let mut buf = vec![0u8; 65536];
501 loop {
502 let n = match stdout.read(&mut buf) {
503 Ok(0) => break, Ok(n) => n,
505 Err(e) => {
506 let _ = tx_out.send(Err(e));
507 break;
508 }
509 };
510 if tx_out
511 .send(Ok(P4StreamEvent::Stdout(buf[..n].to_vec())))
512 .is_err()
513 {
514 break;
515 }
516 }
517 }));
518
519 let tx_err = tx.clone();
521 handles.push(thread::spawn(move || {
522 let mut buf = vec![0u8; 65536];
523 loop {
524 let n = match stderr.read(&mut buf) {
525 Ok(0) => break,
526 Ok(n) => n,
527 Err(e) => {
528 let _ = tx_err.send(Err(e));
529 break;
530 }
531 };
532 if tx_err
533 .send(Ok(P4StreamEvent::Stderr(buf[..n].to_vec())))
534 .is_err()
535 {
536 break;
537 }
538 }
539 }));
540
541 Ok(P4Stream {
542 rx,
543 child: Some(child),
544 handles,
545 exhausted: false,
546 })
547 }
548}
549
550fn wait_process(child: &mut Child, timeout: Option<Duration>) -> std::io::Result<ExitStatus> {
552 match timeout {
553 None => child.wait(),
554 Some(t) => wait_with_timeout(child, t),
555 }
556}
557
558fn wait_with_timeout(child: &mut Child, timeout: Duration) -> std::io::Result<ExitStatus> {
559 let start = std::time::Instant::now();
560 loop {
561 if let Some(status) = child.try_wait()? {
562 return Ok(status);
563 }
564 if start.elapsed() >= timeout {
565 child.kill()?;
566 return child.wait();
567 }
568 thread::sleep(Duration::from_millis(50));
569 }
570}
571
572impl P4Cli {
577 pub fn new() -> std::io::Result<Self> {
583 let (bin_path, temp_dir) = write_p4_cli_to_disk()?;
584 Ok(Self {
585 bin_path,
586 _temp_dir: temp_dir,
587 })
588 }
589
590 pub fn run<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Output> {
594 self.command().args(args).run()
595 }
596
597 pub fn stream<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Stream> {
601 self.command().args(args).stream()
602 }
603
604 pub fn command(&self) -> P4Command<'_> {
607 P4Command::new(self)
608 }
609}
610
611impl Drop for P4Cli {
612 fn drop(&mut self) {
613 let _ = std::fs::remove_dir_all(&self._temp_dir);
614 }
615}
616
617#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
626 fn test_run_help() -> std::io::Result<()> {
627 let p4 = P4Cli::new()?;
628 let output = p4.run(&["--help"])?;
629 assert!(output.success(), "p4 --help should exit with 0");
630 let stdout = output
631 .stdout_str()
632 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
633 assert!(
634 stdout.contains("Usage:"),
635 "expected --help output to contain 'Usage:'"
636 );
637 Ok(())
638 }
639
640 #[test]
641 fn test_run_error() -> std::io::Result<()> {
642 let p4 = P4Cli::new()?;
643 let output = p4.run(&["--nonexistent-flag"])?;
644 assert!(!output.success(), "unknown flag should exit non-zero");
645 let stderr = output
646 .stderr_str()
647 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
648 assert!(
649 stderr.contains("Invalid option") || stderr.contains("error"),
650 "expected error output, got: {stderr}"
651 );
652 Ok(())
653 }
654
655 #[test]
656 fn test_multiple_instances() -> std::io::Result<()> {
657 let p4_a = P4Cli::new()?;
658 let p4_b = P4Cli::new()?;
659 assert!(p4_a.run(&["--help"])?.success());
660 assert!(p4_b.run(&["--help"])?.success());
661 Ok(())
662 }
663
664 #[test]
665 fn test_command_builder() -> std::io::Result<()> {
666 let p4 = P4Cli::new()?;
667 let output = p4.command().arg("--help").run()?;
668 assert!(output.success());
669 Ok(())
670 }
671
672 #[test]
673 fn test_timeout_kills() -> std::io::Result<()> {
674 let p4 = P4Cli::new()?;
675 let output = p4
677 .command()
678 .arg("help")
679 .timeout(Duration::from_millis(1))
680 .run()?;
681 assert!(!output.success() || output.exit_code() == 0);
684 Ok(())
685 }
686
687 #[test]
688 fn test_stream_help() -> std::io::Result<()> {
689 let p4 = P4Cli::new()?;
690 let mut saw_stdout = false;
691 let mut saw_exit = false;
692 for event in p4.stream(&["--help"])? {
693 match &event? {
694 P4StreamEvent::Stdout(data) => {
695 if let Ok(text) = std::str::from_utf8(data)
696 && text.contains("Usage:")
697 {
698 saw_stdout = true;
699 }
700 }
701 P4StreamEvent::Stderr(_) => {}
702 P4StreamEvent::Exit(code) => {
703 assert_eq!(*code, 0);
704 saw_exit = true;
705 }
706 }
707 }
708 assert!(saw_stdout, "expected --help to contain 'Usage:'");
709 assert!(saw_exit, "expected Exit event");
710 Ok(())
711 }
712
713 #[test]
714 fn test_stream_error() -> std::io::Result<()> {
715 let p4 = P4Cli::new()?;
716 let mut saw_stderr = false;
717 let mut saw_exit = false;
718 for event in p4.stream(&["--nonexistent-flag"])? {
719 match &event? {
720 P4StreamEvent::Stdout(_) => {}
721 P4StreamEvent::Stderr(data) => {
722 if let Ok(text) = std::str::from_utf8(data)
723 && (text.contains("Invalid option") || text.contains("error"))
724 {
725 saw_stderr = true;
726 }
727 }
728 P4StreamEvent::Exit(code) => {
729 assert_ne!(*code, 0, "nonexistent flag should fail");
730 saw_exit = true;
731 }
732 }
733 }
734 assert!(saw_stderr, "expected error output");
735 assert!(saw_exit, "expected Exit event");
736 Ok(())
737 }
738
739 #[test]
740 fn test_stream_drop_midway() -> std::io::Result<()> {
741 let p4 = P4Cli::new()?;
743 let stream = p4.stream(&["--help"])?;
744 drop(stream);
745 Ok(())
746 }
747
748 #[test]
749 fn test_stream_builder() -> std::io::Result<()> {
750 let p4 = P4Cli::new()?;
751 let mut saw_exit = false;
752 for event in p4.command().arg("--help").stream()? {
753 if let P4StreamEvent::Exit(code) = event? {
754 assert_eq!(code, 0);
755 saw_exit = true;
756 }
757 }
758 assert!(saw_exit);
759 Ok(())
760 }
761}