Skip to main content

perfgate_adapters/
lib.rs

1//! Std adapters for perfgate.
2//!
3//! In clean-arch terms: this is where we touch the world.
4
5mod fake;
6
7pub use fake::FakeProcessRunner;
8
9use anyhow::Context;
10use perfgate_sha256::sha256_hex;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, Instant};
13
14#[derive(Debug, Clone)]
15pub struct CommandSpec {
16    pub argv: Vec<String>,
17    pub cwd: Option<PathBuf>,
18    pub env: Vec<(String, String)>,
19    pub timeout: Option<Duration>,
20    pub output_cap_bytes: usize,
21}
22
23#[derive(Debug, Clone)]
24pub struct RunResult {
25    pub wall_ms: u64,
26    pub exit_code: i32,
27    pub timed_out: bool,
28    /// CPU time (user + system) in milliseconds.
29    /// Collected on Unix via rusage and best-effort on Windows.
30    pub cpu_ms: Option<u64>,
31    /// Major page faults (Unix only).
32    pub page_faults: Option<u64>,
33    /// Voluntary + involuntary context switches (Unix only).
34    pub ctx_switches: Option<u64>,
35    /// Peak resident set size in KB.
36    /// Collected on Unix via rusage and best-effort on Windows.
37    pub max_rss_kb: Option<u64>,
38    /// Size of executed binary in bytes (best-effort).
39    pub binary_bytes: Option<u64>,
40    pub stdout: Vec<u8>,
41    pub stderr: Vec<u8>,
42}
43
44#[derive(Debug, thiserror::Error)]
45pub enum AdapterError {
46    #[error("command argv must not be empty")]
47    EmptyArgv,
48
49    #[error("command timed out")]
50    Timeout,
51
52    #[error("timeout is not supported on this platform")]
53    TimeoutUnsupported,
54
55    #[error(transparent)]
56    Other(#[from] anyhow::Error),
57}
58
59pub trait ProcessRunner {
60    fn run(&self, spec: &CommandSpec) -> Result<RunResult, AdapterError>;
61}
62
63#[derive(Debug, Default, Clone)]
64pub struct StdProcessRunner;
65
66impl ProcessRunner for StdProcessRunner {
67    fn run(&self, spec: &CommandSpec) -> Result<RunResult, AdapterError> {
68        if spec.argv.is_empty() {
69            return Err(AdapterError::EmptyArgv);
70        }
71
72        #[cfg(unix)]
73        {
74            run_unix(spec)
75        }
76
77        #[cfg(windows)]
78        {
79            if spec.timeout.is_some() {
80                return Err(AdapterError::TimeoutUnsupported);
81            }
82            run_windows(spec)
83        }
84
85        #[cfg(all(not(unix), not(windows)))]
86        {
87            if spec.timeout.is_some() {
88                return Err(AdapterError::TimeoutUnsupported);
89            }
90            run_portable(spec)
91        }
92    }
93}
94
95#[allow(dead_code)]
96fn truncate(mut bytes: Vec<u8>, cap: usize) -> Vec<u8> {
97    if bytes.len() > cap {
98        bytes.truncate(cap);
99    }
100    bytes
101}
102
103#[cfg(all(not(unix), not(windows)))]
104fn run_portable(spec: &CommandSpec) -> Result<RunResult, AdapterError> {
105    use std::process::Command;
106
107    let start = Instant::now();
108    let binary_bytes = binary_bytes_for_command(spec);
109    let mut cmd = Command::new(&spec.argv[0]);
110    if spec.argv.len() > 1 {
111        cmd.args(&spec.argv[1..]);
112    }
113
114    if let Some(cwd) = &spec.cwd {
115        cmd.current_dir(cwd);
116    }
117
118    for (k, v) in &spec.env {
119        cmd.env(k, v);
120    }
121
122    let out = cmd
123        .output()
124        .with_context(|| format!("failed to run {:?}", spec.argv))
125        .map_err(AdapterError::Other)?;
126
127    let wall_ms = start.elapsed().as_millis() as u64;
128    let exit_code = out.status.code().unwrap_or(-1);
129
130    Ok(RunResult {
131        wall_ms,
132        exit_code,
133        timed_out: false,
134        cpu_ms: None,
135        page_faults: None,
136        ctx_switches: None,
137        max_rss_kb: None,
138        binary_bytes,
139        stdout: truncate(out.stdout, spec.output_cap_bytes),
140        stderr: truncate(out.stderr, spec.output_cap_bytes),
141    })
142}
143
144#[cfg(windows)]
145fn run_windows(spec: &CommandSpec) -> Result<RunResult, AdapterError> {
146    use std::os::windows::io::AsRawHandle;
147    use std::process::{Command, Stdio};
148    use std::thread;
149
150    let start = Instant::now();
151    let binary_bytes = binary_bytes_for_command(spec);
152
153    let mut cmd = Command::new(&spec.argv[0]);
154    if spec.argv.len() > 1 {
155        cmd.args(&spec.argv[1..]);
156    }
157    if let Some(cwd) = &spec.cwd {
158        cmd.current_dir(cwd);
159    }
160    for (k, v) in &spec.env {
161        cmd.env(k, v);
162    }
163
164    cmd.stdin(Stdio::null());
165    cmd.stdout(Stdio::piped());
166    cmd.stderr(Stdio::piped());
167
168    let mut child = cmd
169        .spawn()
170        .with_context(|| format!("failed to spawn {:?}", spec.argv))
171        .map_err(AdapterError::Other)?;
172
173    let mut stdout = child.stdout.take().expect("stdout piped");
174    let mut stderr = child.stderr.take().expect("stderr piped");
175    let cap = spec.output_cap_bytes;
176
177    let out_handle = thread::spawn(move || read_with_cap(&mut stdout, cap));
178    let err_handle = thread::spawn(move || read_with_cap(&mut stderr, cap));
179
180    let status = child
181        .wait()
182        .with_context(|| format!("failed to wait for {:?}", spec.argv))
183        .map_err(AdapterError::Other)?;
184
185    let (cpu_ms, max_rss_kb, page_faults) = probe_process_usage_windows(child.as_raw_handle());
186
187    let stdout = out_handle.join().unwrap_or_default();
188    let stderr = err_handle.join().unwrap_or_default();
189
190    let wall_ms = start.elapsed().as_millis() as u64;
191    let exit_code = status.code().unwrap_or(-1);
192
193    Ok(RunResult {
194        wall_ms,
195        exit_code,
196        timed_out: false,
197        cpu_ms,
198        page_faults,
199        ctx_switches: None,
200        max_rss_kb,
201        binary_bytes,
202        stdout,
203        stderr,
204    })
205}
206
207#[cfg(unix)]
208fn run_unix(spec: &CommandSpec) -> Result<RunResult, AdapterError> {
209    use std::os::unix::process::ExitStatusExt;
210    use std::process::{Command, Stdio};
211    use std::thread;
212
213    let start = Instant::now();
214    let binary_bytes = binary_bytes_for_command(spec);
215
216    let mut cmd = Command::new(&spec.argv[0]);
217    if spec.argv.len() > 1 {
218        cmd.args(&spec.argv[1..]);
219    }
220
221    if let Some(cwd) = &spec.cwd {
222        cmd.current_dir(cwd);
223    }
224
225    for (k, v) in &spec.env {
226        cmd.env(k, v);
227    }
228
229    cmd.stdin(Stdio::null());
230    cmd.stdout(Stdio::piped());
231    cmd.stderr(Stdio::piped());
232
233    let mut child = cmd
234        .spawn()
235        .with_context(|| format!("failed to spawn {:?}", spec.argv))
236        .map_err(AdapterError::Other)?;
237
238    let pid = child.id() as libc::pid_t;
239
240    let mut stdout = child.stdout.take().expect("stdout piped");
241    let mut stderr = child.stderr.take().expect("stderr piped");
242
243    let cap = spec.output_cap_bytes;
244
245    let out_handle = thread::spawn(move || read_with_cap(&mut stdout, cap));
246    let err_handle = thread::spawn(move || read_with_cap(&mut stderr, cap));
247
248    let (status_raw, rusage, timed_out) = wait4_with_timeout(pid, spec.timeout)?;
249
250    // Safety: we have reaped the child via wait4; drop the Child handle without waiting.
251    drop(child);
252
253    let stdout = out_handle.join().unwrap_or_default();
254    let stderr = err_handle.join().unwrap_or_default();
255
256    let wall_ms = start.elapsed().as_millis() as u64;
257
258    let exit_status = std::process::ExitStatus::from_raw(status_raw);
259    let exit_code = exit_status.code().unwrap_or(-1);
260
261    let cpu_ms = rusage.map(|ru| ru_cpu_ms(&ru));
262    let page_faults = rusage.map(|ru| ru_page_faults(&ru));
263    let ctx_switches = rusage.map(|ru| ru_ctx_switches(&ru));
264    let max_rss_kb = rusage.map(|ru| ru_maxrss_kb(&ru));
265
266    Ok(RunResult {
267        wall_ms,
268        exit_code,
269        timed_out,
270        cpu_ms,
271        page_faults,
272        ctx_switches,
273        max_rss_kb,
274        binary_bytes,
275        stdout,
276        stderr,
277    })
278}
279
280fn read_with_cap<R: std::io::Read>(reader: &mut R, cap: usize) -> Vec<u8> {
281    let mut buf: Vec<u8> = Vec::new();
282    let mut tmp = [0u8; 8192];
283
284    loop {
285        match reader.read(&mut tmp) {
286            Ok(0) => break,
287            Ok(n) => {
288                if buf.len() < cap {
289                    let remaining = cap - buf.len();
290                    let take = remaining.min(n);
291                    buf.extend_from_slice(&tmp[..take]);
292                }
293            }
294            Err(_) => break,
295        }
296    }
297
298    buf
299}
300
301#[cfg(windows)]
302fn probe_process_usage_windows(
303    handle: std::os::windows::io::RawHandle,
304) -> (Option<u64>, Option<u64>, Option<u64>) {
305    use std::ffi::c_void;
306    use std::mem;
307
308    #[repr(C)]
309    #[allow(non_snake_case)]
310    struct FileTime {
311        dwLowDateTime: u32,
312        dwHighDateTime: u32,
313    }
314
315    #[repr(C)]
316    #[allow(non_snake_case)]
317    struct ProcessMemoryCounters {
318        cb: u32,
319        PageFaultCount: u32,
320        PeakWorkingSetSize: usize,
321        WorkingSetSize: usize,
322        QuotaPeakPagedPoolUsage: usize,
323        QuotaPagedPoolUsage: usize,
324        QuotaPeakNonPagedPoolUsage: usize,
325        QuotaNonPagedPoolUsage: usize,
326        PagefileUsage: usize,
327        PeakPagefileUsage: usize,
328    }
329
330    #[link(name = "kernel32")]
331    unsafe extern "system" {
332        fn GetProcessTimes(
333            hProcess: *mut c_void,
334            lpCreationTime: *mut FileTime,
335            lpExitTime: *mut FileTime,
336            lpKernelTime: *mut FileTime,
337            lpUserTime: *mut FileTime,
338        ) -> i32;
339    }
340
341    #[link(name = "psapi")]
342    unsafe extern "system" {
343        fn GetProcessMemoryInfo(
344            Process: *mut c_void,
345            ppsmemCounters: *mut ProcessMemoryCounters,
346            cb: u32,
347        ) -> i32;
348    }
349
350    fn filetime_to_u64(ft: &FileTime) -> u64 {
351        ((ft.dwHighDateTime as u64) << 32) | (ft.dwLowDateTime as u64)
352    }
353
354    let raw = handle.cast::<c_void>();
355
356    let mut creation: FileTime = unsafe { mem::zeroed() };
357    let mut exit: FileTime = unsafe { mem::zeroed() };
358    let mut kernel: FileTime = unsafe { mem::zeroed() };
359    let mut user: FileTime = unsafe { mem::zeroed() };
360
361    let cpu_ms =
362        if unsafe { GetProcessTimes(raw, &mut creation, &mut exit, &mut kernel, &mut user) } != 0 {
363            let total_100ns = filetime_to_u64(&kernel).saturating_add(filetime_to_u64(&user));
364            Some(total_100ns / 10_000)
365        } else {
366            None
367        };
368
369    let mut counters: ProcessMemoryCounters = unsafe { mem::zeroed() };
370    counters.cb = mem::size_of::<ProcessMemoryCounters>() as u32;
371    let (max_rss_kb, page_faults) =
372        if unsafe { GetProcessMemoryInfo(raw, &mut counters, counters.cb) } != 0 {
373            (
374                Some((counters.PeakWorkingSetSize as u64) / 1024),
375                Some(counters.PageFaultCount as u64),
376            )
377        } else {
378            (None, None)
379        };
380
381    (cpu_ms, max_rss_kb, page_faults)
382}
383
384#[cfg(unix)]
385fn wait4_with_timeout(
386    pid: libc::pid_t,
387    timeout: Option<Duration>,
388) -> Result<(libc::c_int, Option<libc::rusage>, bool), AdapterError> {
389    use std::mem;
390
391    let start = Instant::now();
392    let mut status: libc::c_int = 0;
393    let mut ru: libc::rusage = unsafe { mem::zeroed() };
394
395    let mut timed_out = false;
396
397    loop {
398        let options = if timeout.is_some() { libc::WNOHANG } else { 0 };
399
400        let res = unsafe { libc::wait4(pid, &mut status as *mut libc::c_int, options, &mut ru) };
401
402        if res == pid {
403            break;
404        }
405
406        if res == 0 {
407            // still running
408            if let Some(t) = timeout
409                && start.elapsed() >= t
410            {
411                timed_out = true;
412                unsafe {
413                    libc::kill(pid, libc::SIGKILL);
414                }
415                // Reap it.
416                let res2 = unsafe { libc::wait4(pid, &mut status as *mut libc::c_int, 0, &mut ru) };
417                if res2 != pid {
418                    return Err(AdapterError::Other(anyhow::anyhow!(
419                        "wait4 after kill failed: {:?}",
420                        std::io::Error::last_os_error()
421                    )));
422                }
423                break;
424            }
425            std::thread::sleep(Duration::from_millis(10));
426            continue;
427        }
428
429        if res == -1 {
430            let err = std::io::Error::last_os_error();
431            if err.kind() == std::io::ErrorKind::Interrupted {
432                continue;
433            }
434            return Err(AdapterError::Other(anyhow::anyhow!("wait4 failed: {err}")));
435        }
436
437        // Any other pid is unexpected.
438        return Err(AdapterError::Other(anyhow::anyhow!(
439            "wait4 returned unexpected pid: {res}"
440        )));
441    }
442
443    Ok((status, Some(ru), timed_out))
444}
445
446#[cfg(unix)]
447fn ru_cpu_ms(ru: &libc::rusage) -> u64 {
448    // ru_utime and ru_stime are timeval structs with tv_sec (seconds) and tv_usec (microseconds)
449    let user_ms = (ru.ru_utime.tv_sec as u64) * 1000 + (ru.ru_utime.tv_usec as u64) / 1000;
450    let sys_ms = (ru.ru_stime.tv_sec as u64) * 1000 + (ru.ru_stime.tv_usec as u64) / 1000;
451    user_ms + sys_ms
452}
453
454#[cfg(unix)]
455fn ru_page_faults(ru: &libc::rusage) -> u64 {
456    // ru_majflt is major page faults.
457    clamp_nonnegative_c_long(ru.ru_majflt)
458}
459
460#[cfg(unix)]
461fn ru_ctx_switches(ru: &libc::rusage) -> u64 {
462    // ru_nvcsw: voluntary; ru_nivcsw: involuntary context switches.
463    clamp_nonnegative_c_long(ru.ru_nvcsw).saturating_add(clamp_nonnegative_c_long(ru.ru_nivcsw))
464}
465
466#[cfg(unix)]
467fn clamp_nonnegative_c_long(v: libc::c_long) -> u64 {
468    if v < 0 { 0 } else { v as u64 }
469}
470
471#[cfg(unix)]
472fn ru_maxrss_kb(ru: &libc::rusage) -> u64 {
473    let raw = ru.ru_maxrss as u64;
474
475    // On Linux, ru_maxrss is KB.
476    // On macOS, ru_maxrss is bytes.
477    #[cfg(target_os = "macos")]
478    {
479        raw / 1024
480    }
481
482    #[cfg(not(target_os = "macos"))]
483    {
484        raw
485    }
486}
487
488fn binary_bytes_for_command(spec: &CommandSpec) -> Option<u64> {
489    let cmd = spec.argv.first()?;
490    let path = resolve_command_path(cmd, spec.cwd.as_deref())?;
491    std::fs::metadata(path).ok().map(|m| m.len())
492}
493
494fn resolve_command_path(command: &str, cwd: Option<&Path>) -> Option<PathBuf> {
495    let command_path = Path::new(command);
496
497    // If command contains separators or is absolute, resolve directly (relative to cwd if provided).
498    if command_path.is_absolute() || command_path.components().count() > 1 {
499        let candidate = if command_path.is_absolute() {
500            command_path.to_path_buf()
501        } else if let Some(dir) = cwd {
502            dir.join(command_path)
503        } else {
504            command_path.to_path_buf()
505        };
506        return candidate.is_file().then_some(candidate);
507    }
508
509    // Otherwise, resolve via PATH lookup.
510    let path_var = std::env::var_os("PATH")?;
511    for dir in std::env::split_paths(&path_var) {
512        let candidate = dir.join(command);
513        if candidate.is_file() {
514            return Some(candidate);
515        }
516
517        #[cfg(windows)]
518        {
519            if candidate.extension().is_none() {
520                let pathext = std::env::var_os("PATHEXT").unwrap_or(".COM;.EXE;.BAT;.CMD".into());
521                for ext in pathext.to_string_lossy().split(';') {
522                    let ext = ext.trim();
523                    if ext.is_empty() {
524                        continue;
525                    }
526                    let mut with_ext = candidate.clone();
527                    let normalized = ext.trim_start_matches('.');
528                    with_ext.set_extension(normalized);
529                    if with_ext.is_file() {
530                        return Some(with_ext);
531                    }
532                }
533            }
534        }
535    }
536
537    None
538}
539
540// ----------------------------
541// Host fingerprinting
542// ----------------------------
543
544use perfgate_types::HostInfo;
545
546/// Options for host probing.
547#[derive(Debug, Clone, Default)]
548pub struct HostProbeOptions {
549    /// If true, include a SHA-256 hash of the hostname for fingerprinting.
550    /// This is opt-in for privacy reasons.
551    pub include_hostname_hash: bool,
552}
553
554/// Trait for probing host system information.
555pub trait HostProbe {
556    /// Probe the current host and return system information.
557    fn probe(&self, options: &HostProbeOptions) -> HostInfo;
558}
559
560/// Standard implementation of HostProbe using platform APIs.
561#[derive(Debug, Default, Clone)]
562pub struct StdHostProbe;
563
564impl HostProbe for StdHostProbe {
565    fn probe(&self, options: &HostProbeOptions) -> HostInfo {
566        HostInfo {
567            os: std::env::consts::OS.to_string(),
568            arch: std::env::consts::ARCH.to_string(),
569            cpu_count: probe_cpu_count(),
570            memory_bytes: probe_memory_bytes(),
571            hostname_hash: if options.include_hostname_hash {
572                probe_hostname_hash()
573            } else {
574                None
575            },
576        }
577    }
578}
579
580/// Get the number of logical CPUs.
581/// Returns None if the count cannot be determined.
582fn probe_cpu_count() -> Option<u32> {
583    // Use std::thread::available_parallelism which is available since Rust 1.59
584    std::thread::available_parallelism()
585        .ok()
586        .map(|n| n.get() as u32)
587}
588
589/// Get total system memory in bytes.
590/// Returns None if memory cannot be determined.
591fn probe_memory_bytes() -> Option<u64> {
592    #[cfg(target_os = "linux")]
593    {
594        probe_memory_linux()
595    }
596
597    #[cfg(target_os = "macos")]
598    {
599        probe_memory_macos()
600    }
601
602    #[cfg(target_os = "windows")]
603    {
604        probe_memory_windows()
605    }
606
607    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
608    {
609        None
610    }
611}
612
613#[cfg(target_os = "linux")]
614fn probe_memory_linux() -> Option<u64> {
615    // Read from /proc/meminfo
616    let content = std::fs::read_to_string("/proc/meminfo").ok()?;
617    for line in content.lines() {
618        if line.starts_with("MemTotal:") {
619            // Format: "MemTotal:       16384000 kB"
620            let parts: Vec<&str> = line.split_whitespace().collect();
621            if parts.len() >= 2
622                && let Ok(kb) = parts[1].parse::<u64>()
623            {
624                return Some(kb * 1024); // Convert KB to bytes
625            }
626        }
627    }
628    None
629}
630
631#[cfg(target_os = "macos")]
632fn probe_memory_macos() -> Option<u64> {
633    // Use sysctl to get hw.memsize
634    use std::mem;
635
636    let mut memsize: u64 = 0;
637    let mut size = mem::size_of::<u64>();
638    let name = c"hw.memsize";
639
640    let ret = unsafe {
641        libc::sysctlbyname(
642            name.as_ptr(),
643            &mut memsize as *mut u64 as *mut libc::c_void,
644            &mut size,
645            std::ptr::null_mut(),
646            0,
647        )
648    };
649
650    if ret == 0 { Some(memsize) } else { None }
651}
652
653#[cfg(target_os = "windows")]
654fn probe_memory_windows() -> Option<u64> {
655    use std::mem;
656
657    #[repr(C)]
658    #[allow(non_snake_case)]
659    struct MemoryStatusEx {
660        dwLength: u32,
661        dwMemoryLoad: u32,
662        ullTotalPhys: u64,
663        ullAvailPhys: u64,
664        ullTotalPageFile: u64,
665        ullAvailPageFile: u64,
666        ullTotalVirtual: u64,
667        ullAvailVirtual: u64,
668        ullAvailExtendedVirtual: u64,
669    }
670
671    #[link(name = "kernel32")]
672    unsafe extern "system" {
673        fn GlobalMemoryStatusEx(lpBuffer: *mut MemoryStatusEx) -> i32;
674    }
675
676    let mut status: MemoryStatusEx = unsafe { mem::zeroed() };
677    status.dwLength = mem::size_of::<MemoryStatusEx>() as u32;
678
679    let ret = unsafe { GlobalMemoryStatusEx(&mut status) };
680
681    if ret != 0 {
682        Some(status.ullTotalPhys)
683    } else {
684        None
685    }
686}
687
688/// Get a SHA-256 hash of the hostname.
689/// Returns None if hostname cannot be determined.
690fn probe_hostname_hash() -> Option<String> {
691    let hostname = hostname::get().ok()?;
692    let hostname_str = hostname.to_string_lossy();
693    Some(sha256_hex(hostname_str.as_bytes()))
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use proptest::prelude::*;
700
701    // **Feature: comprehensive-test-coverage, Property 8: Output Truncation Invariant**
702    // *For any* byte sequence and cap value, the truncated output SHALL have length `min(original_length, cap)`.
703    //
704    // **Validates: Requirements 9.3**
705    proptest! {
706        #![proptest_config(ProptestConfig::with_cases(100))]
707
708        /// Property test: truncated length equals min(original_length, cap)
709        #[test]
710        fn truncate_length_equals_min_of_original_and_cap(
711            bytes in proptest::collection::vec(any::<u8>(), 0..1000),
712            cap in 0usize..2000
713        ) {
714            let original_len = bytes.len();
715            let result = truncate(bytes, cap);
716            let expected_len = original_len.min(cap);
717            prop_assert_eq!(
718                result.len(),
719                expected_len,
720                "truncated length should be min({}, {}) = {}, but got {}",
721                original_len,
722                cap,
723                expected_len,
724                result.len()
725            );
726        }
727
728        /// Property test: truncated content is a prefix of the original
729        #[test]
730        fn truncate_preserves_prefix(
731            bytes in proptest::collection::vec(any::<u8>(), 0..1000),
732            cap in 0usize..2000
733        ) {
734            let original = bytes.clone();
735            let result = truncate(bytes, cap);
736
737            // The result should be a prefix of the original
738            prop_assert!(
739                original.starts_with(&result),
740                "truncated output should be a prefix of the original"
741            );
742        }
743
744        /// Property test: when cap >= original_length, output equals original
745        #[test]
746        fn truncate_no_op_when_cap_exceeds_length(
747            bytes in proptest::collection::vec(any::<u8>(), 0..500)
748        ) {
749            let original = bytes.clone();
750            let original_len = original.len();
751            // Use a cap that is >= original length
752            let cap = original_len + 100;
753            let result = truncate(bytes, cap);
754
755            prop_assert_eq!(
756                result,
757                original,
758                "when cap ({}) >= original_length ({}), output should equal original",
759                cap,
760                original_len
761            );
762        }
763    }
764
765    // Additional unit tests for edge cases
766    #[test]
767    fn truncate_empty_vec() {
768        let result = truncate(vec![], 10);
769        assert_eq!(result, Vec::<u8>::new());
770    }
771
772    #[test]
773    fn truncate_with_zero_cap() {
774        let result = truncate(vec![1, 2, 3, 4, 5], 0);
775        assert_eq!(result, Vec::<u8>::new());
776    }
777
778    #[test]
779    fn truncate_exact_cap() {
780        let bytes = vec![1, 2, 3, 4, 5];
781        let result = truncate(bytes.clone(), 5);
782        assert_eq!(result, bytes);
783    }
784
785    #[test]
786    fn truncate_one_over_cap() {
787        let bytes = vec![1, 2, 3, 4, 5];
788        let result = truncate(bytes, 4);
789        assert_eq!(result, vec![1, 2, 3, 4]);
790    }
791
792    // =========================================================================
793    // Unit tests for adapter error conditions
794    // Validates: Requirements 11.3
795    // =========================================================================
796
797    /// Test that StdProcessRunner::run returns AdapterError::EmptyArgv when argv is empty
798    #[test]
799    fn empty_argv_returns_error() {
800        let runner = StdProcessRunner;
801        let spec = CommandSpec {
802            argv: vec![],
803            cwd: None,
804            env: vec![],
805            timeout: None,
806            output_cap_bytes: 1024,
807        };
808
809        let result = runner.run(&spec);
810        assert!(result.is_err(), "Expected error for empty argv");
811
812        let err = result.unwrap_err();
813        assert!(
814            matches!(err, AdapterError::EmptyArgv),
815            "Expected AdapterError::EmptyArgv, got {:?}",
816            err
817        );
818    }
819
820    /// Test that AdapterError::EmptyArgv has a descriptive error message
821    #[test]
822    fn empty_argv_error_message_is_descriptive() {
823        let err = AdapterError::EmptyArgv;
824        let msg = err.to_string();
825        assert!(
826            msg.contains("argv") && msg.contains("empty"),
827            "Error message should mention 'argv' and 'empty', got: {}",
828            msg
829        );
830    }
831
832    /// Test that AdapterError::Timeout has a descriptive error message
833    #[test]
834    fn timeout_error_message_is_descriptive() {
835        let err = AdapterError::Timeout;
836        let msg = err.to_string();
837        assert!(
838            msg.contains("timed out") || msg.contains("timeout"),
839            "Error message should mention 'timeout', got: {}",
840            msg
841        );
842    }
843
844    /// Test that AdapterError::TimeoutUnsupported has a descriptive error message
845    #[test]
846    fn timeout_unsupported_error_message_is_descriptive() {
847        let err = AdapterError::TimeoutUnsupported;
848        let msg = err.to_string();
849        assert!(
850            msg.contains("timeout") && msg.contains("not supported"),
851            "Error message should mention 'timeout' and 'not supported', got: {}",
852            msg
853        );
854    }
855
856    /// Test that on non-Unix platforms, timeout returns TimeoutUnsupported error
857    /// On Unix platforms, this test verifies the timeout functionality works
858    #[cfg(not(unix))]
859    #[test]
860    fn timeout_on_non_unix_returns_unsupported() {
861        let runner = StdProcessRunner;
862        let spec = CommandSpec {
863            argv: vec!["echo".to_string(), "hello".to_string()],
864            cwd: None,
865            env: vec![],
866            timeout: Some(Duration::from_secs(10)),
867            output_cap_bytes: 1024,
868        };
869
870        let result = runner.run(&spec);
871        assert!(result.is_err(), "Expected error for timeout on non-Unix");
872
873        let err = result.unwrap_err();
874        assert!(
875            matches!(err, AdapterError::TimeoutUnsupported),
876            "Expected AdapterError::TimeoutUnsupported, got {:?}",
877            err
878        );
879    }
880
881    /// Test that Windows collects best-effort CPU and RSS metrics.
882    #[cfg(windows)]
883    #[test]
884    fn windows_collects_best_effort_metrics() {
885        let runner = StdProcessRunner;
886        let spec = CommandSpec {
887            argv: vec![
888                "cmd".to_string(),
889                "/c".to_string(),
890                "echo".to_string(),
891                "hello".to_string(),
892            ],
893            cwd: None,
894            env: vec![],
895            timeout: None,
896            output_cap_bytes: 1024,
897        };
898
899        let result = runner.run(&spec).expect("windows run should succeed");
900        assert_eq!(result.exit_code, 0, "command should succeed");
901        assert!(
902            result.cpu_ms.is_some(),
903            "cpu_ms should be available on Windows (best-effort)"
904        );
905        assert!(
906            result.max_rss_kb.is_some(),
907            "max_rss_kb should be available on Windows (best-effort)"
908        );
909        assert!(
910            result.page_faults.is_some(),
911            "page_faults should be available on Windows (best-effort)"
912        );
913    }
914
915    /// Test that on Unix platforms, timeout is supported and works correctly
916    #[cfg(unix)]
917    #[test]
918    fn timeout_on_unix_is_supported() {
919        let runner = StdProcessRunner;
920        // Use a command that completes quickly
921        let spec = CommandSpec {
922            argv: vec!["echo".to_string(), "hello".to_string()],
923            cwd: None,
924            env: vec![],
925            timeout: Some(Duration::from_secs(10)),
926            output_cap_bytes: 1024,
927        };
928
929        let result = runner.run(&spec);
930        assert!(
931            result.is_ok(),
932            "Timeout should be supported on Unix, got error: {:?}",
933            result.err()
934        );
935
936        let run_result = result.unwrap();
937        assert!(!run_result.timed_out, "Command should not have timed out");
938        assert_eq!(run_result.exit_code, 0, "Command should have succeeded");
939    }
940
941    /// Test that on Unix platforms, a command that exceeds timeout is killed
942    #[cfg(unix)]
943    #[test]
944    fn timeout_kills_long_running_command() {
945        let runner = StdProcessRunner;
946        // Use sleep command that would take longer than the timeout
947        let spec = CommandSpec {
948            argv: vec!["sleep".to_string(), "10".to_string()],
949            cwd: None,
950            env: vec![],
951            timeout: Some(Duration::from_millis(100)),
952            output_cap_bytes: 1024,
953        };
954
955        let start = std::time::Instant::now();
956        let result = runner.run(&spec);
957        let elapsed = start.elapsed();
958
959        assert!(
960            result.is_ok(),
961            "Should return Ok with timed_out flag, got error: {:?}",
962            result.err()
963        );
964
965        let run_result = result.unwrap();
966        assert!(run_result.timed_out, "Command should have timed out");
967
968        // Verify the command was killed within a reasonable time (not the full 10 seconds)
969        assert!(
970            elapsed < Duration::from_secs(2),
971            "Command should have been killed quickly, but took {:?}",
972            elapsed
973        );
974    }
975
976    /// Test that AdapterError::Other wraps anyhow errors correctly
977    #[test]
978    fn other_error_wraps_anyhow() {
979        let inner_err = anyhow::anyhow!("test error message");
980        let err = AdapterError::Other(inner_err);
981        let msg = err.to_string();
982        assert!(
983            msg.contains("test error message"),
984            "Error message should contain the inner error, got: {}",
985            msg
986        );
987    }
988
989    /// Test that empty argv check happens before any process spawning
990    #[test]
991    fn empty_argv_check_is_immediate() {
992        let runner = StdProcessRunner;
993        let spec = CommandSpec {
994            argv: vec![],
995            cwd: Some(std::path::PathBuf::from("/nonexistent/path")),
996            env: vec![("SOME_VAR".to_string(), "value".to_string())],
997            timeout: Some(Duration::from_secs(1)),
998            output_cap_bytes: 1024,
999        };
1000
1001        // This should return EmptyArgv error immediately, not fail due to
1002        // invalid cwd or other issues
1003        let result = runner.run(&spec);
1004        assert!(
1005            matches!(result, Err(AdapterError::EmptyArgv)),
1006            "Should return EmptyArgv before checking other parameters"
1007        );
1008    }
1009
1010    // =========================================================================
1011    // Unit tests for host fingerprinting
1012    // Validates: Host info probing for noise mitigation
1013    // =========================================================================
1014
1015    /// Test that StdHostProbe returns valid os and arch strings
1016    #[test]
1017    fn host_probe_returns_valid_os_arch() {
1018        let probe = StdHostProbe;
1019        let options = HostProbeOptions::default();
1020        let info = probe.probe(&options);
1021
1022        // os should be non-empty and match std::env::consts::OS
1023        assert!(!info.os.is_empty(), "os should not be empty");
1024        assert_eq!(info.os, std::env::consts::OS);
1025
1026        // arch should be non-empty and match std::env::consts::ARCH
1027        assert!(!info.arch.is_empty(), "arch should not be empty");
1028        assert_eq!(info.arch, std::env::consts::ARCH);
1029    }
1030
1031    /// Test that cpu_count is populated on most platforms
1032    #[test]
1033    fn host_probe_returns_cpu_count() {
1034        let probe = StdHostProbe;
1035        let options = HostProbeOptions::default();
1036        let info = probe.probe(&options);
1037
1038        // cpu_count should be Some on most platforms
1039        // We test that if available, it's a sensible value
1040        if let Some(count) = info.cpu_count {
1041            assert!(count >= 1, "cpu_count should be at least 1, got {}", count);
1042            assert!(
1043                count <= 1024,
1044                "cpu_count should be at most 1024, got {}",
1045                count
1046            );
1047        }
1048    }
1049
1050    /// Test that memory_bytes is populated on most platforms
1051    #[test]
1052    fn host_probe_returns_memory() {
1053        let probe = StdHostProbe;
1054        let options = HostProbeOptions::default();
1055        let info = probe.probe(&options);
1056
1057        // memory_bytes should be Some on most platforms
1058        // We test that if available, it's a sensible value (at least 128MB, at most 128TB)
1059        if let Some(bytes) = info.memory_bytes {
1060            assert!(
1061                bytes >= 128 * 1024 * 1024,
1062                "memory_bytes should be at least 128MB, got {}",
1063                bytes
1064            );
1065            assert!(
1066                bytes <= 128 * 1024 * 1024 * 1024 * 1024,
1067                "memory_bytes should be at most 128TB, got {}",
1068                bytes
1069            );
1070        }
1071    }
1072
1073    /// Test that hostname_hash is None when not requested
1074    #[test]
1075    fn host_probe_no_hostname_by_default() {
1076        let probe = StdHostProbe;
1077        let options = HostProbeOptions {
1078            include_hostname_hash: false,
1079        };
1080        let info = probe.probe(&options);
1081
1082        assert!(
1083            info.hostname_hash.is_none(),
1084            "hostname_hash should be None when not requested"
1085        );
1086    }
1087
1088    /// Test that hostname_hash is populated when requested
1089    #[test]
1090    fn host_probe_returns_hostname_hash_when_requested() {
1091        let probe = StdHostProbe;
1092        let options = HostProbeOptions {
1093            include_hostname_hash: true,
1094        };
1095        let info = probe.probe(&options);
1096
1097        // hostname_hash should be Some and be a valid SHA-256 hex string (64 chars)
1098        if let Some(hash) = &info.hostname_hash {
1099            let hash_len = hash.len();
1100            assert_eq!(
1101                hash_len, 64,
1102                "hostname_hash should be 64 hex chars, got {}",
1103                hash_len
1104            );
1105            assert!(
1106                hash.chars().all(|c| c.is_ascii_hexdigit()),
1107                "hostname_hash should be hex, got {}",
1108                hash
1109            );
1110        }
1111        // Note: hostname_hash might be None if hostname cannot be determined
1112    }
1113
1114    /// Test that the same hostname produces the same hash (deterministic)
1115    #[test]
1116    fn hostname_hash_is_deterministic() {
1117        let probe = StdHostProbe;
1118        let options = HostProbeOptions {
1119            include_hostname_hash: true,
1120        };
1121
1122        let info1 = probe.probe(&options);
1123        let info2 = probe.probe(&options);
1124
1125        assert_eq!(
1126            info1.hostname_hash, info2.hostname_hash,
1127            "hostname_hash should be deterministic"
1128        );
1129    }
1130
1131    // =========================================================================
1132    // Integration tests for StdProcessRunner running real commands
1133    // =========================================================================
1134
1135    #[test]
1136    fn std_runner_executes_real_command() {
1137        let runner = StdProcessRunner;
1138        let spec = CommandSpec {
1139            argv: if cfg!(windows) {
1140                vec!["cmd".into(), "/c".into(), "echo".into(), "hello".into()]
1141            } else {
1142                vec!["/bin/sh".into(), "-c".into(), "echo hello".into()]
1143            },
1144            cwd: None,
1145            env: vec![],
1146            timeout: None,
1147            output_cap_bytes: 4096,
1148        };
1149
1150        let result = runner.run(&spec).expect("echo should succeed");
1151        assert_eq!(result.exit_code, 0);
1152        assert!(!result.timed_out);
1153
1154        let stdout_str = String::from_utf8_lossy(&result.stdout);
1155        assert!(
1156            stdout_str.contains("hello"),
1157            "stdout should contain 'hello', got: {:?}",
1158            stdout_str
1159        );
1160    }
1161
1162    #[test]
1163    fn std_runner_populates_samples_fields() {
1164        let runner = StdProcessRunner;
1165        let spec = CommandSpec {
1166            argv: if cfg!(windows) {
1167                vec![
1168                    "cmd".into(),
1169                    "/c".into(),
1170                    "echo".into(),
1171                    "test_output".into(),
1172                ]
1173            } else {
1174                vec!["/bin/sh".into(), "-c".into(), "echo test_output".into()]
1175            },
1176            cwd: None,
1177            env: vec![],
1178            timeout: None,
1179            output_cap_bytes: 4096,
1180        };
1181
1182        let result = runner.run(&spec).expect("run should succeed");
1183        assert_eq!(result.exit_code, 0);
1184        assert!(!result.timed_out);
1185        // Stdout should be populated
1186        assert!(
1187            !result.stdout.is_empty(),
1188            "stdout should not be empty for echo command"
1189        );
1190        let stdout_str = String::from_utf8_lossy(&result.stdout);
1191        assert!(stdout_str.contains("test_output"));
1192    }
1193
1194    #[test]
1195    fn std_runner_with_env_vars() {
1196        let runner = StdProcessRunner;
1197        let spec = CommandSpec {
1198            argv: if cfg!(windows) {
1199                vec![
1200                    "cmd".into(),
1201                    "/c".into(),
1202                    "echo".into(),
1203                    "%PERFGATE_TEST_VAR%".into(),
1204                ]
1205            } else {
1206                vec![
1207                    "/bin/sh".into(),
1208                    "-c".into(),
1209                    "echo $PERFGATE_TEST_VAR".into(),
1210                ]
1211            },
1212            cwd: None,
1213            env: vec![("PERFGATE_TEST_VAR".to_string(), "custom_value".to_string())],
1214            timeout: None,
1215            output_cap_bytes: 4096,
1216        };
1217
1218        let result = runner.run(&spec).expect("run with env vars should succeed");
1219        assert_eq!(result.exit_code, 0);
1220        let stdout_str = String::from_utf8_lossy(&result.stdout);
1221        assert!(
1222            stdout_str.contains("custom_value"),
1223            "stdout should contain env var value, got: {:?}",
1224            stdout_str
1225        );
1226    }
1227
1228    #[test]
1229    fn std_runner_invalid_command_returns_error() {
1230        let runner = StdProcessRunner;
1231        let spec = CommandSpec {
1232            argv: vec!["nonexistent_binary_that_does_not_exist_12345".to_string()],
1233            cwd: None,
1234            env: vec![],
1235            timeout: None,
1236            output_cap_bytes: 4096,
1237        };
1238
1239        let result = runner.run(&spec);
1240        assert!(
1241            result.is_err(),
1242            "running a nonexistent binary should return an error"
1243        );
1244    }
1245
1246    #[test]
1247    fn std_runner_captures_stderr() {
1248        let runner = StdProcessRunner;
1249        let spec = CommandSpec {
1250            argv: if cfg!(windows) {
1251                vec![
1252                    "cmd".into(),
1253                    "/c".into(),
1254                    "echo".into(),
1255                    "err_msg".into(),
1256                    "1>&2".into(),
1257                ]
1258            } else {
1259                vec!["/bin/sh".into(), "-c".into(), "echo err_msg >&2".into()]
1260            },
1261            cwd: None,
1262            env: vec![],
1263            timeout: None,
1264            output_cap_bytes: 4096,
1265        };
1266
1267        let result = runner.run(&spec).expect("run should succeed");
1268        let stderr_str = String::from_utf8_lossy(&result.stderr);
1269        assert!(
1270            stderr_str.contains("err_msg"),
1271            "stderr should contain 'err_msg', got: {:?}",
1272            stderr_str
1273        );
1274    }
1275
1276    #[test]
1277    fn std_runner_nonzero_exit_code() {
1278        let runner = StdProcessRunner;
1279        let spec = CommandSpec {
1280            argv: if cfg!(windows) {
1281                vec!["cmd".into(), "/c".into(), "exit".into(), "42".into()]
1282            } else {
1283                vec!["/bin/sh".into(), "-c".into(), "exit 42".into()]
1284            },
1285            cwd: None,
1286            env: vec![],
1287            timeout: None,
1288            output_cap_bytes: 4096,
1289        };
1290
1291        let result = runner
1292            .run(&spec)
1293            .expect("run should succeed even with nonzero exit");
1294        assert_eq!(result.exit_code, 42);
1295        assert!(!result.timed_out);
1296    }
1297
1298    // =========================================================================
1299    // Edge-case tests: process runner error paths
1300    // =========================================================================
1301
1302    /// Non-existent command returns AdapterError::Other (not EmptyArgv).
1303    #[test]
1304    fn nonexistent_command_returns_other_error() {
1305        let runner = StdProcessRunner;
1306        let spec = CommandSpec {
1307            argv: vec!["__perfgate_nonexistent_cmd_xyz__".into()],
1308            cwd: None,
1309            env: vec![],
1310            timeout: None,
1311            output_cap_bytes: 4096,
1312        };
1313
1314        let err = runner.run(&spec).unwrap_err();
1315        assert!(
1316            matches!(err, AdapterError::Other(_)),
1317            "Expected AdapterError::Other for missing binary, got: {err:?}",
1318        );
1319    }
1320
1321    /// Non-zero exit with empty stdout and stderr.
1322    #[test]
1323    fn nonzero_exit_empty_output() {
1324        let runner = StdProcessRunner;
1325        let spec = CommandSpec {
1326            argv: if cfg!(windows) {
1327                vec!["cmd".into(), "/c".into(), "exit".into(), "1".into()]
1328            } else {
1329                vec!["/bin/sh".into(), "-c".into(), "exit 1".into()]
1330            },
1331            cwd: None,
1332            env: vec![],
1333            timeout: None,
1334            output_cap_bytes: 4096,
1335        };
1336
1337        let result = runner.run(&spec).expect("should succeed with nonzero exit");
1338        assert_eq!(result.exit_code, 1);
1339        assert!(result.stdout.is_empty(), "stdout should be empty");
1340        // stderr may or may not be empty depending on platform
1341    }
1342
1343    /// Command that produces no stdout at all.
1344    #[test]
1345    fn command_with_no_stdout() {
1346        let runner = StdProcessRunner;
1347        let spec = CommandSpec {
1348            argv: if cfg!(windows) {
1349                // `cmd /c rem` produces no output
1350                vec!["cmd".into(), "/c".into(), "rem".into()]
1351            } else {
1352                vec!["/bin/sh".into(), "-c".into(), "true".into()]
1353            },
1354            cwd: None,
1355            env: vec![],
1356            timeout: None,
1357            output_cap_bytes: 4096,
1358        };
1359
1360        let result = runner.run(&spec).expect("silent command should succeed");
1361        assert_eq!(result.exit_code, 0);
1362        assert!(
1363            result.stdout.is_empty(),
1364            "stdout should be empty for silent command"
1365        );
1366    }
1367
1368    /// Invalid working directory returns an error.
1369    #[test]
1370    fn invalid_cwd_returns_error() {
1371        let runner = StdProcessRunner;
1372        let spec = CommandSpec {
1373            argv: if cfg!(windows) {
1374                vec!["cmd".into(), "/c".into(), "echo".into(), "hi".into()]
1375            } else {
1376                vec!["/bin/sh".into(), "-c".into(), "echo hi".into()]
1377            },
1378            cwd: Some(PathBuf::from(
1379                "__perfgate_nonexistent_dir_xyz__/deeply/nested",
1380            )),
1381            env: vec![],
1382            timeout: None,
1383            output_cap_bytes: 4096,
1384        };
1385
1386        let result = runner.run(&spec);
1387        assert!(result.is_err(), "invalid cwd should cause an error");
1388    }
1389
1390    /// Output that exceeds the cap is truncated.
1391    #[test]
1392    fn output_cap_truncates_large_stdout() {
1393        let runner = StdProcessRunner;
1394        // Generate output larger than the cap
1395        let spec = CommandSpec {
1396            argv: if cfg!(windows) {
1397                // Use a loop to generate lots of output
1398                vec![
1399                    "cmd".into(),
1400                    "/c".into(),
1401                    "for /L %i in (1,1,500) do @echo AAAAAAAAAA".into(),
1402                ]
1403            } else {
1404                vec![
1405                    "/bin/sh".into(),
1406                    "-c".into(),
1407                    "yes AAAAAAAAAA | head -n 500".into(),
1408                ]
1409            },
1410            cwd: None,
1411            env: vec![],
1412            timeout: None,
1413            output_cap_bytes: 64,
1414        };
1415
1416        let result = runner.run(&spec).expect("command should succeed");
1417        assert!(
1418            result.stdout.len() <= 64,
1419            "stdout should be capped at 64 bytes, got {}",
1420            result.stdout.len()
1421        );
1422    }
1423
1424    /// Zero output cap means no output is captured.
1425    #[test]
1426    fn zero_output_cap_captures_nothing() {
1427        let runner = StdProcessRunner;
1428        let spec = CommandSpec {
1429            argv: if cfg!(windows) {
1430                vec!["cmd".into(), "/c".into(), "echo".into(), "hello".into()]
1431            } else {
1432                vec!["/bin/sh".into(), "-c".into(), "echo hello".into()]
1433            },
1434            cwd: None,
1435            env: vec![],
1436            timeout: None,
1437            output_cap_bytes: 0,
1438        };
1439
1440        let result = runner.run(&spec).expect("command should succeed");
1441        assert_eq!(result.exit_code, 0);
1442        assert!(
1443            result.stdout.is_empty(),
1444            "stdout should be empty with zero cap"
1445        );
1446        assert!(
1447            result.stderr.is_empty(),
1448            "stderr should be empty with zero cap"
1449        );
1450    }
1451
1452    // =========================================================================
1453    // Edge-case tests: system metrics for zero-length processes
1454    // =========================================================================
1455
1456    /// An instant-exit process should still produce valid metrics.
1457    #[test]
1458    fn instant_exit_process_has_valid_metrics() {
1459        let runner = StdProcessRunner;
1460        let spec = CommandSpec {
1461            argv: if cfg!(windows) {
1462                vec!["cmd".into(), "/c".into(), "exit".into(), "0".into()]
1463            } else {
1464                vec!["/bin/sh".into(), "-c".into(), "true".into()]
1465            },
1466            cwd: None,
1467            env: vec![],
1468            timeout: None,
1469            output_cap_bytes: 4096,
1470        };
1471
1472        let result = runner.run(&spec).expect("instant exit should succeed");
1473        assert_eq!(result.exit_code, 0);
1474        assert!(!result.timed_out);
1475        // wall_ms should be small (under 5 seconds at most)
1476        assert!(
1477            result.wall_ms < 5000,
1478            "wall_ms should be small for instant process, got {}",
1479            result.wall_ms,
1480        );
1481    }
1482
1483    /// CPU metrics for an instant-exit process should be zero or very small.
1484    #[test]
1485    fn instant_exit_cpu_ms_is_small() {
1486        let runner = StdProcessRunner;
1487        let spec = CommandSpec {
1488            argv: if cfg!(windows) {
1489                vec!["cmd".into(), "/c".into(), "exit".into(), "0".into()]
1490            } else {
1491                vec!["/bin/sh".into(), "-c".into(), "true".into()]
1492            },
1493            cwd: None,
1494            env: vec![],
1495            timeout: None,
1496            output_cap_bytes: 4096,
1497        };
1498
1499        let result = runner.run(&spec).expect("instant exit should succeed");
1500        if let Some(cpu) = result.cpu_ms {
1501            assert!(
1502                cpu < 1000,
1503                "cpu_ms should be under 1s for instant process, got {}",
1504                cpu,
1505            );
1506        }
1507    }
1508
1509    /// binary_bytes is populated for well-known commands.
1510    #[test]
1511    fn binary_bytes_populated_for_known_command() {
1512        let runner = StdProcessRunner;
1513        let spec = CommandSpec {
1514            argv: if cfg!(windows) {
1515                vec!["cmd".into(), "/c".into(), "echo".into(), "x".into()]
1516            } else {
1517                vec!["/bin/sh".into(), "-c".into(), "true".into()]
1518            },
1519            cwd: None,
1520            env: vec![],
1521            timeout: None,
1522            output_cap_bytes: 4096,
1523        };
1524
1525        let result = runner.run(&spec).expect("should succeed");
1526        // The binary should be resolvable on most systems
1527        if let Some(bytes) = result.binary_bytes {
1528            assert!(bytes > 0, "binary_bytes should be > 0 when present");
1529        }
1530    }
1531
1532    // =========================================================================
1533    // Edge-case tests: platform capability detection
1534    // =========================================================================
1535
1536    /// On Windows, ctx_switches are not collected.
1537    #[cfg(windows)]
1538    #[test]
1539    fn windows_does_not_collect_unix_only_metrics() {
1540        let runner = StdProcessRunner;
1541        let spec = CommandSpec {
1542            argv: vec!["cmd".into(), "/c".into(), "echo".into(), "hi".into()],
1543            cwd: None,
1544            env: vec![],
1545            timeout: None,
1546            output_cap_bytes: 4096,
1547        };
1548
1549        let result = runner.run(&spec).expect("should succeed on Windows");
1550        assert!(
1551            result.ctx_switches.is_none(),
1552            "ctx_switches should be None on Windows"
1553        );
1554    }
1555
1556    /// On Windows, timeouts are unsupported.
1557    #[cfg(windows)]
1558    #[test]
1559    fn windows_timeout_returns_unsupported() {
1560        let runner = StdProcessRunner;
1561        let spec = CommandSpec {
1562            argv: vec!["cmd".into(), "/c".into(), "echo".into(), "hi".into()],
1563            cwd: None,
1564            env: vec![],
1565            timeout: Some(Duration::from_secs(5)),
1566            output_cap_bytes: 4096,
1567        };
1568
1569        let err = runner.run(&spec).unwrap_err();
1570        assert!(
1571            matches!(err, AdapterError::TimeoutUnsupported),
1572            "Expected TimeoutUnsupported on Windows, got: {err:?}",
1573        );
1574    }
1575
1576    /// On Unix, all resource-usage metrics are collected.
1577    #[cfg(unix)]
1578    #[test]
1579    fn unix_collects_all_rusage_metrics() {
1580        let runner = StdProcessRunner;
1581        let spec = CommandSpec {
1582            argv: vec!["/bin/sh".into(), "-c".into(), "echo hi".into()],
1583            cwd: None,
1584            env: vec![],
1585            timeout: None,
1586            output_cap_bytes: 4096,
1587        };
1588
1589        let result = runner.run(&spec).expect("should succeed on Unix");
1590        assert!(result.cpu_ms.is_some(), "cpu_ms should be Some on Unix");
1591        assert!(
1592            result.page_faults.is_some(),
1593            "page_faults should be Some on Unix"
1594        );
1595        assert!(
1596            result.ctx_switches.is_some(),
1597            "ctx_switches should be Some on Unix"
1598        );
1599        assert!(
1600            result.max_rss_kb.is_some(),
1601            "max_rss_kb should be Some on Unix"
1602        );
1603    }
1604
1605    /// HostProbe always reports the current platform OS/arch.
1606    #[test]
1607    fn host_probe_os_matches_platform() {
1608        let probe = StdHostProbe;
1609        let info = probe.probe(&HostProbeOptions::default());
1610
1611        if cfg!(windows) {
1612            assert_eq!(info.os, "windows");
1613        } else if cfg!(target_os = "linux") {
1614            assert_eq!(info.os, "linux");
1615        } else if cfg!(target_os = "macos") {
1616            assert_eq!(info.os, "macos");
1617        }
1618
1619        if cfg!(target_arch = "x86_64") {
1620            assert_eq!(info.arch, "x86_64");
1621        } else if cfg!(target_arch = "aarch64") {
1622            assert_eq!(info.arch, "aarch64");
1623        }
1624    }
1625
1626    /// read_with_cap returns empty vec for an empty reader.
1627    #[test]
1628    fn read_with_cap_empty_reader() {
1629        let mut reader: &[u8] = &[];
1630        let result = read_with_cap(&mut reader, 1024);
1631        assert!(result.is_empty());
1632    }
1633
1634    /// read_with_cap with zero cap returns empty vec even with data.
1635    #[test]
1636    fn read_with_cap_zero_cap() {
1637        let mut reader: &[u8] = b"hello world";
1638        let result = read_with_cap(&mut reader, 0);
1639        assert!(result.is_empty());
1640    }
1641
1642    /// read_with_cap truncates to cap.
1643    #[test]
1644    fn read_with_cap_truncates() {
1645        let mut reader: &[u8] = b"hello world";
1646        let result = read_with_cap(&mut reader, 5);
1647        assert_eq!(result, b"hello");
1648    }
1649}