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 {
13 bin_path: PathBuf,
14 _temp_dir: PathBuf,
15}
16
17pub struct P4Output {
22 exit_code: i32,
23 stdout: Vec<u8>,
24 stderr: Vec<u8>,
25}
26
27impl P4Output {
28 pub fn exit_code(&self) -> i32 {
29 self.exit_code
30 }
31
32 pub fn success(&self) -> bool {
34 self.exit_code == 0
35 }
36
37 pub fn stdout(&self) -> &[u8] {
39 &self.stdout
40 }
41
42 pub fn stderr(&self) -> &[u8] {
44 &self.stderr
45 }
46
47 pub fn stdout_str(&self) -> Result<&str, std::str::Utf8Error> {
49 std::str::from_utf8(&self.stdout)
50 }
51
52 pub fn stderr_str(&self) -> Result<&str, std::str::Utf8Error> {
54 std::str::from_utf8(&self.stderr)
55 }
56
57 pub fn stdout_lines(&self) -> Result<Vec<&str>, std::str::Utf8Error> {
59 let s = self.stdout_str()?;
60 if s.is_empty() {
61 Ok(Vec::new())
62 } else {
63 Ok(s.lines().collect())
64 }
65 }
66
67 pub fn stderr_lines(&self) -> Result<Vec<&str>, std::str::Utf8Error> {
69 let s = self.stderr_str()?;
70 if s.is_empty() {
71 Ok(Vec::new())
72 } else {
73 Ok(s.lines().collect())
74 }
75 }
76}
77
78impl std::fmt::Debug for P4Output {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.debug_struct("P4Output")
81 .field("exit_code", &self.exit_code)
82 .field("stdout_len", &self.stdout.len())
83 .field("stderr_len", &self.stderr.len())
84 .finish()
85 }
86}
87
88fn base_dir() -> PathBuf {
94 std::env::temp_dir().join("p4cli-20251")
95}
96
97fn fingerprint(data: &[u8]) -> String {
102 let n = data.len();
103 let prefix = &data[..data.len().min(64)];
104 let hex: String = prefix.iter().map(|b| format!("{:02x}", b)).collect();
105 format!("{}_{}", n, hex)
106}
107
108fn cleanup_stale_dirs(base: &PathBuf, cutoff: std::time::SystemTime) {
112 let Ok(entries) = std::fs::read_dir(base) else {
113 return;
114 };
115 for entry in entries.flatten() {
116 let path = entry.path();
117 if !path.is_dir() {
118 continue;
119 }
120 if path.file_name().is_some_and(|n| n == ".cache") {
122 continue;
123 }
124 if let Ok(meta) = path.metadata()
125 && let Ok(mtime) = meta.modified()
126 && mtime < cutoff
127 {
128 let _ = std::fs::remove_dir_all(&path);
129 }
130 }
131}
132
133fn write_p4_cli_to_disk() -> std::io::Result<(PathBuf, PathBuf)> {
141 let zst_data = get_p4_cli_zst();
142 let fp = fingerprint(&zst_data);
143
144 let base = base_dir();
145 std::fs::create_dir_all(&base)?;
146
147 let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
149 cleanup_stale_dirs(&base, cutoff);
150
151 let cache_dir = base.join(".cache").join(&fp);
153 let cache_bin = cache_dir.join("p4_binary");
154 if !cache_bin.exists() {
155 std::fs::create_dir_all(&cache_dir)?;
156 let binary_data = decompress_zst(&zst_data)?;
157 let tmp = cache_dir.join(format!(".tmp.{}", std::process::id()));
158 {
159 let mut f = std::fs::File::create(&tmp)?;
160 f.write_all(&binary_data)?;
161 f.sync_all()?;
162 }
163 std::fs::rename(&tmp, &cache_bin)?;
164 set_executable_perms(&cache_bin)?;
165 }
166
167 let dir = base.join(format!(
169 "{}_{}",
170 std::process::id(),
171 std::time::SystemTime::now()
172 .duration_since(std::time::UNIX_EPOCH)
173 .map(|d| d.as_nanos())
174 .unwrap_or(0)
175 ));
176 std::fs::create_dir(&dir)?;
177 let bin_path = dir.join("p4_binary");
178
179 std::fs::copy(&cache_bin, &bin_path)?;
181 set_executable_perms(&bin_path)?;
182
183 Ok((bin_path, dir))
184}
185
186fn decompress_zst(zst_data: &[u8]) -> std::io::Result<Vec<u8>> {
187 let mut decoder = zstd::stream::Decoder::new(zst_data)?;
188 let mut buf = Vec::new();
189 std::io::copy(&mut decoder, &mut buf)?;
190 Ok(buf)
191}
192
193#[cfg(unix)]
194fn set_executable_perms(path: &std::path::Path) -> std::io::Result<()> {
195 use std::os::unix::fs::PermissionsExt;
196 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
197}
198
199#[cfg(not(unix))]
200fn set_executable_perms(_path: &std::path::Path) -> std::io::Result<()> {
201 Ok(())
202}
203
204fn get_p4_cli_zst() -> Vec<u8> {
209 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
210 {
211 use p4cli_20251_win_x64::get_p4_cli_zst;
212 get_p4_cli_zst()
213 }
214
215 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
216 {
217 use p4cli_20251_mac_arm64::get_p4_cli_zst;
218 get_p4_cli_zst()
219 }
220
221 #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
222 {
223 use p4cli_20251_mac_x64::get_p4_cli_zst;
224 get_p4_cli_zst()
225 }
226
227 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
228 {
229 use p4cli_20251_linux_x64::get_p4_cli_zst;
230 get_p4_cli_zst()
231 }
232
233 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
234 {
235 use p4cli_20251_linux_arm64::get_p4_cli_zst;
236 get_p4_cli_zst()
237 }
238
239 #[cfg(not(any(
240 all(target_os = "windows", target_arch = "x86_64"),
241 all(target_os = "macos", target_arch = "aarch64"),
242 all(target_os = "macos", target_arch = "x86_64"),
243 all(target_os = "linux", target_arch = "x86_64"),
244 all(target_os = "linux", target_arch = "aarch64")
245 )))]
246 {
247 compile_error!(format!(
248 "Unsupported platform: {}-{}",
249 std::env::consts::OS,
250 std::env::consts::ARCH
251 ));
252 Vec::new()
253 }
254}
255
256pub struct P4Command<'a> {
265 cli: &'a P4Cli,
266 args: Vec<std::ffi::OsString>,
267 timeout: Option<Duration>,
268 cwd: Option<PathBuf>,
269 envs: Vec<(std::ffi::OsString, std::ffi::OsString)>,
270 stdin_data: Option<Vec<u8>>,
271}
272
273impl<'a> P4Command<'a> {
274 fn new(cli: &'a P4Cli) -> Self {
275 Self {
276 cli,
277 args: Vec::new(),
278 timeout: None,
279 cwd: None,
280 envs: Vec::new(),
281 stdin_data: None,
282 }
283 }
284
285 pub fn arg(&mut self, arg: impl AsRef<std::ffi::OsStr>) -> &mut Self {
287 self.args.push(arg.as_ref().to_os_string());
288 self
289 }
290
291 pub fn args(&mut self, args: &[impl AsRef<std::ffi::OsStr>]) -> &mut Self {
293 self.args
294 .extend(args.iter().map(|a| a.as_ref().to_os_string()));
295 self
296 }
297
298 pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
301 self.timeout = Some(timeout);
302 self
303 }
304
305 pub fn cwd(&mut self, path: impl Into<PathBuf>) -> &mut Self {
307 self.cwd = Some(path.into());
308 self
309 }
310
311 pub fn env(
313 &mut self,
314 key: impl Into<std::ffi::OsString>,
315 val: impl Into<std::ffi::OsString>,
316 ) -> &mut Self {
317 self.envs.push((key.into(), val.into()));
318 self
319 }
320
321 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> {
333 let mut cmd = Command::new(&self.cli.bin_path);
334 cmd.args(&self.args)
335 .stdout(Stdio::piped())
336 .stderr(Stdio::piped());
337
338 if self.stdin_data.is_some() {
339 cmd.stdin(Stdio::piped());
340 } else {
341 cmd.stdin(Stdio::null());
342 }
343
344 if let Some(ref cwd) = self.cwd {
345 cmd.current_dir(cwd);
346 }
347 for (k, v) in &self.envs {
348 cmd.env(k, v);
349 }
350
351 let mut child = cmd.spawn()?;
352
353 if let Some(data) = self.stdin_data.take()
355 && let Some(mut stdin) = child.stdin.take()
356 {
357 thread::spawn(move || {
358 let _ = stdin.write_all(&data);
359 });
360 }
361
362 let stdout = child
363 .stdout
364 .take()
365 .ok_or_else(|| std::io::Error::other("stdout was not captured"))?;
366 let stderr = child
367 .stderr
368 .take()
369 .ok_or_else(|| std::io::Error::other("stderr was not captured"))?;
370
371 let stdout_handle = thread::spawn(move || {
373 let mut buf = Vec::new();
374 BufReader::new(stdout).read_to_end(&mut buf)?;
375 Ok::<_, std::io::Error>(buf)
376 });
377
378 let stderr_handle = thread::spawn(move || {
379 let mut buf = Vec::new();
380 BufReader::new(stderr).read_to_end(&mut buf)?;
381 Ok::<_, std::io::Error>(buf)
382 });
383
384 let exit_status = wait_process(&mut child, self.timeout)?;
386
387 let stdout_buf = stdout_handle
388 .join()
389 .map_err(|_| std::io::Error::other("stdout reader thread panicked"))?
390 .map_err(|e| std::io::Error::other(format!("stdout read failed: {e}")))?;
391 let stderr_buf = stderr_handle
392 .join()
393 .map_err(|_| std::io::Error::other("stderr reader thread panicked"))?
394 .map_err(|e| std::io::Error::other(format!("stderr read failed: {e}")))?;
395
396 Ok(P4Output {
397 exit_code: exit_status.code().unwrap_or(-1),
398 stdout: stdout_buf,
399 stderr: stderr_buf,
400 })
401 }
402}
403
404fn wait_process(child: &mut Child, timeout: Option<Duration>) -> std::io::Result<ExitStatus> {
406 match timeout {
407 None => child.wait(),
408 Some(t) => wait_with_timeout(child, t),
409 }
410}
411
412fn wait_with_timeout(child: &mut Child, timeout: Duration) -> std::io::Result<ExitStatus> {
413 let start = std::time::Instant::now();
414 loop {
415 if let Some(status) = child.try_wait()? {
416 return Ok(status);
417 }
418 if start.elapsed() >= timeout {
419 child.kill()?;
420 return child.wait();
421 }
422 thread::sleep(Duration::from_millis(50));
423 }
424}
425
426impl P4Cli {
431 pub fn new() -> std::io::Result<Self> {
437 let (bin_path, temp_dir) = write_p4_cli_to_disk()?;
438 Ok(Self {
439 bin_path,
440 _temp_dir: temp_dir,
441 })
442 }
443
444 pub fn run<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Output> {
448 self.command().args(args).run()
449 }
450
451 pub fn command(&self) -> P4Command<'_> {
454 P4Command::new(self)
455 }
456}
457
458impl Drop for P4Cli {
459 fn drop(&mut self) {
460 let _ = std::fs::remove_dir_all(&self._temp_dir);
461 }
462}
463
464#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn test_run_help() -> std::io::Result<()> {
474 let p4 = P4Cli::new()?;
475 let output = p4.run(&["--help"])?;
476 assert!(output.success(), "p4 --help should exit with 0");
477 let stdout = output
478 .stdout_str()
479 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
480 assert!(
481 stdout.contains("Usage:"),
482 "expected --help output to contain 'Usage:'"
483 );
484 Ok(())
485 }
486
487 #[test]
488 fn test_run_error() -> std::io::Result<()> {
489 let p4 = P4Cli::new()?;
490 let output = p4.run(&["--nonexistent-flag"])?;
491 assert!(!output.success(), "unknown flag should exit non-zero");
492 let stderr = output
493 .stderr_str()
494 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
495 assert!(
496 stderr.contains("Invalid option") || stderr.contains("error"),
497 "expected error output, got: {stderr}"
498 );
499 Ok(())
500 }
501
502 #[test]
503 fn test_multiple_instances() -> std::io::Result<()> {
504 let p4_a = P4Cli::new()?;
505 let p4_b = P4Cli::new()?;
506 assert!(p4_a.run(&["--help"])?.success());
507 assert!(p4_b.run(&["--help"])?.success());
508 Ok(())
509 }
510
511 #[test]
512 fn test_command_builder() -> std::io::Result<()> {
513 let p4 = P4Cli::new()?;
514 let output = p4.command().arg("--help").run()?;
515 assert!(output.success());
516 Ok(())
517 }
518
519 #[test]
520 fn test_timeout_kills() -> std::io::Result<()> {
521 let p4 = P4Cli::new()?;
522 let output = p4
524 .command()
525 .arg("help")
526 .timeout(Duration::from_millis(1))
527 .run()?;
528 assert!(!output.success() || output.exit_code() == 0);
531 Ok(())
532 }
533}