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
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(String),
95 Stderr(String),
97 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
111pub struct P4Stream {
125 rx: std::sync::mpsc::Receiver<std::io::Result<P4StreamEvent>>,
126 child: Option<Child>,
127 #[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 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 if let Some(ref mut child) = self.child {
162 let _ = child.kill();
163 let _ = child.wait();
164 }
165 }
167}
168
169fn 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 {
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
225fn 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
277pub 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 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 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 pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
325 self.timeout = Some(timeout);
326 self
327 }
328
329 pub fn cwd(&mut self, path: impl Into<PathBuf>) -> &mut Self {
331 self.cwd = Some(path.into());
332 self
333 }
334
335 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 pub fn stdin(&mut self, data: impl Into<Vec<u8>>) -> &mut Self {
347 self.stdin_data = Some(data.into());
348 self
349 }
350
351 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 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 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 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 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 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 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
522fn 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
544impl P4Cli {
549 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 pub fn run<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Output> {
566 self.command().args(args).run()
567 }
568
569 pub fn stream<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Stream> {
573 self.command().args(args).stream()
574 }
575
576 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#[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 let output = p4
649 .command()
650 .arg("help")
651 .timeout(Duration::from_millis(1))
652 .run()?;
653 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 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}