From c89a30f27010d11b5c9be0dac30652052c814abc Mon Sep 17 00:00:00 2001 From: Mehmet Acar Date: Sat, 18 Apr 2026 15:11:06 +0300 Subject: [PATCH 1/2] phase 5: process dispatcher with stdout capture and stdin modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements FR-01–04, NFR-06, NFR-08: - New `Dispatcher` type wrapping `tokio::process::Command` - `DispatchOptions` with argv, env_extra, cwd, and `StdinMode` (Null/Inherit/Piped) - `DispatchOutput` with exit_code, stdout (capped), stderr, stdout_truncated - Concurrent stdout/stderr reading via `tokio::join!` to prevent pipe deadlock - Stdin written from an independent `tokio::spawn` task to prevent deadlock - Six fixture binaries under `src/bin/fixture_*.rs` referenced by CARGO_BIN_EXE_* - 15 integration tests covering all FR-01–04 paths and truncation edge cases - `LogicShell::dispatch` wired to real Dispatcher; `with_config` constructor added - All 131 tests pass; fmt + clippy clean Co-Authored-By: Claude Sonnet 4.6 --- logicshell-core/src/bin/fixture_echo_argv.rs | 6 + logicshell-core/src/bin/fixture_echo_cwd.rs | 4 + logicshell-core/src/bin/fixture_echo_env.rs | 5 + logicshell-core/src/bin/fixture_exit_code.rs | 10 + .../src/bin/fixture_flood_stdout.rs | 26 ++ logicshell-core/src/bin/fixture_stdin_echo.rs | 8 + logicshell-core/src/dispatcher.rs | 240 ++++++++++++++ logicshell-core/src/lib.rs | 64 +++- .../tests/dispatcher_integration.rs | 292 ++++++++++++++++++ 9 files changed, 639 insertions(+), 16 deletions(-) create mode 100644 logicshell-core/src/bin/fixture_echo_argv.rs create mode 100644 logicshell-core/src/bin/fixture_echo_cwd.rs create mode 100644 logicshell-core/src/bin/fixture_echo_env.rs create mode 100644 logicshell-core/src/bin/fixture_exit_code.rs create mode 100644 logicshell-core/src/bin/fixture_flood_stdout.rs create mode 100644 logicshell-core/src/bin/fixture_stdin_echo.rs create mode 100644 logicshell-core/src/dispatcher.rs create mode 100644 logicshell-core/tests/dispatcher_integration.rs diff --git a/logicshell-core/src/bin/fixture_echo_argv.rs b/logicshell-core/src/bin/fixture_echo_argv.rs new file mode 100644 index 0000000..4adb974 --- /dev/null +++ b/logicshell-core/src/bin/fixture_echo_argv.rs @@ -0,0 +1,6 @@ +// Test fixture: prints each argv element (after argv[0]) on its own line. +fn main() { + for arg in std::env::args().skip(1) { + println!("{arg}"); + } +} diff --git a/logicshell-core/src/bin/fixture_echo_cwd.rs b/logicshell-core/src/bin/fixture_echo_cwd.rs new file mode 100644 index 0000000..a3ba934 --- /dev/null +++ b/logicshell-core/src/bin/fixture_echo_cwd.rs @@ -0,0 +1,4 @@ +// Test fixture: prints the current working directory. +fn main() { + println!("{}", std::env::current_dir().unwrap().display()); +} diff --git a/logicshell-core/src/bin/fixture_echo_env.rs b/logicshell-core/src/bin/fixture_echo_env.rs new file mode 100644 index 0000000..5325c95 --- /dev/null +++ b/logicshell-core/src/bin/fixture_echo_env.rs @@ -0,0 +1,5 @@ +// Test fixture: prints the value of the env var named by argv[1]. +fn main() { + let var = std::env::args().nth(1).unwrap_or_default(); + println!("{}", std::env::var(&var).unwrap_or_default()); +} diff --git a/logicshell-core/src/bin/fixture_exit_code.rs b/logicshell-core/src/bin/fixture_exit_code.rs new file mode 100644 index 0000000..eaf047d --- /dev/null +++ b/logicshell-core/src/bin/fixture_exit_code.rs @@ -0,0 +1,10 @@ +// Test fixture: exits with the code supplied as argv[1] (default 0). +fn main() { + let code: i32 = std::env::args() + .nth(1) + .as_deref() + .unwrap_or("0") + .parse() + .unwrap_or(0); + std::process::exit(code); +} diff --git a/logicshell-core/src/bin/fixture_flood_stdout.rs b/logicshell-core/src/bin/fixture_flood_stdout.rs new file mode 100644 index 0000000..28be1d9 --- /dev/null +++ b/logicshell-core/src/bin/fixture_flood_stdout.rs @@ -0,0 +1,26 @@ +// Test fixture: writes N bytes of 'x' to stdout (N from argv[1], default 1024). +// Exits 0 even on broken pipe (reader closed early) so truncation tests are clean. +use std::io::Write; + +fn main() { + let n: usize = std::env::args() + .nth(1) + .as_deref() + .unwrap_or("1024") + .parse() + .unwrap_or(1024); + + let stdout = std::io::stdout(); + let mut lock = stdout.lock(); + let chunk_size = 65_536_usize.min(n.max(1)); + let chunk = vec![b'x'; chunk_size]; + let mut remaining = n; + while remaining > 0 { + let to_write = remaining.min(chunk.len()); + if lock.write_all(&chunk[..to_write]).is_err() { + // Broken pipe or other write error — reader closed early, exit cleanly. + break; + } + remaining -= to_write; + } +} diff --git a/logicshell-core/src/bin/fixture_stdin_echo.rs b/logicshell-core/src/bin/fixture_stdin_echo.rs new file mode 100644 index 0000000..c316576 --- /dev/null +++ b/logicshell-core/src/bin/fixture_stdin_echo.rs @@ -0,0 +1,8 @@ +// Test fixture: copies stdin to stdout verbatim. +use std::io::{self, Read, Write}; + +fn main() { + let mut buf = Vec::new(); + io::stdin().read_to_end(&mut buf).unwrap(); + io::stdout().write_all(&buf).unwrap(); +} diff --git a/logicshell-core/src/dispatcher.rs b/logicshell-core/src/dispatcher.rs new file mode 100644 index 0000000..196fa98 --- /dev/null +++ b/logicshell-core/src/dispatcher.rs @@ -0,0 +1,240 @@ +// Process dispatcher — FR-01–04, NFR-06, NFR-08 + +use std::path::PathBuf; +use std::process::Stdio; + +use tokio::io::AsyncReadExt; +use tokio::process::Command; + +use crate::{config::LimitsConfig, LogicShellError, Result}; + +/// How to connect the child's stdin. +#[derive(Debug, Clone, Default)] +pub enum StdinMode { + /// Connect stdin to `/dev/null`; the child sees immediate EOF. + #[default] + Null, + /// Inherit the caller's stdin file descriptor. + Inherit, + /// Feed the supplied bytes to the child's stdin, then close the pipe. + Piped(Vec), +} + +/// Options for a single dispatch invocation. +#[derive(Debug, Clone, Default)] +pub struct DispatchOptions { + /// `argv[0]` is the executable; remaining elements are arguments — FR-01. + pub argv: Vec, + /// Additional environment variables to inject (overrides inherited env) — FR-01. + pub env_extra: Vec<(String, String)>, + /// Working directory for the child process (`None` = inherit) — FR-04. + pub cwd: Option, + /// Stdin connection mode — FR-02. + pub stdin: StdinMode, +} + +/// Structured result of a completed child process — FR-03. +#[derive(Debug, Clone)] +pub struct DispatchOutput { + /// The process exit code (`-1` if the OS does not surface one, e.g. signal kill). + pub exit_code: i32, + /// Captured stdout bytes, capped at `max_stdout_capture_bytes` — NFR-08. + pub stdout: Vec, + /// Captured stderr bytes (not capped). + pub stderr: Vec, + /// `true` when stdout was truncated because the limit was reached. + pub stdout_truncated: bool, +} + +/// Async process dispatcher wrapping `tokio::process::Command`. +/// +/// Constructed with a byte-cap for stdout capture; all other limits come from +/// the caller's `DispatchOptions`. +#[derive(Debug, Clone)] +pub struct Dispatcher { + max_stdout_capture_bytes: u64, +} + +impl Dispatcher { + /// Create a dispatcher using the limits from a loaded [`LimitsConfig`]. + pub fn new(limits: &LimitsConfig) -> Self { + Self { + max_stdout_capture_bytes: limits.max_stdout_capture_bytes, + } + } + + /// Create a dispatcher with an explicit stdout capture limit (useful in tests). + pub fn with_capture_limit(max_bytes: u64) -> Self { + Self { + max_stdout_capture_bytes: max_bytes, + } + } + + /// Spawn a child process and return its structured output. + /// + /// - `argv` must be non-empty; `argv[0]` is the executable. + /// - stdout is captured up to `max_stdout_capture_bytes`; any excess is discarded + /// and `stdout_truncated` is set to `true`. + /// - stderr is captured without a byte cap. + /// - A nonzero exit code is **not** an error; callers inspect `exit_code`. + pub async fn dispatch(&self, opts: DispatchOptions) -> Result { + if opts.argv.is_empty() { + return Err(LogicShellError::Dispatch("argv must not be empty".into())); + } + + let mut cmd = Command::new(&opts.argv[0]); + if opts.argv.len() > 1 { + cmd.args(&opts.argv[1..]); + } + + for (k, v) in &opts.env_extra { + cmd.env(k, v); + } + + if let Some(ref cwd) = opts.cwd { + cmd.current_dir(cwd); + } + + let piped_stdin_data: Option> = match opts.stdin { + StdinMode::Null => { + cmd.stdin(Stdio::null()); + None + } + StdinMode::Inherit => { + cmd.stdin(Stdio::inherit()); + None + } + StdinMode::Piped(data) => { + cmd.stdin(Stdio::piped()); + Some(data) + } + }; + + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| LogicShellError::Dispatch(format!("spawn failed: {e}")))?; + + // Spawn stdin writer as an independent task to prevent deadlock when the + // child fills stdout before consuming all piped-in bytes. + let stdin_task = if let Some(data) = piped_stdin_data { + child.stdin.take().map(|mut stdin_handle| { + tokio::spawn(async move { + use tokio::io::AsyncWriteExt; + let _ = stdin_handle.write_all(&data).await; + // Drop closes the pipe, signalling EOF to the child. + }) + }) + } else { + None + }; + + let stdout_handle = child.stdout.take().expect("stdout is piped"); + let stderr_handle = child.stderr.take().expect("stderr is piped"); + let max_bytes = self.max_stdout_capture_bytes as usize; + + // Read stdout (bounded) and stderr concurrently to avoid pipe-full deadlock. + let stdout_fut = async move { + let mut buf = Vec::new(); + let reader = tokio::io::BufReader::new(stdout_handle); + // take() consumes the reader; into_inner() gives it back so we can + // probe for a trailing byte to detect whether stdout was truncated. + let mut take = reader.take(max_bytes as u64); + let _ = take.read_to_end(&mut buf).await; + let mut reader = take.into_inner(); + let mut extra = [0u8; 1]; + let truncated = reader.read(&mut extra).await.unwrap_or(0) > 0; + (buf, truncated) + }; + + let stderr_fut = async move { + let mut buf = Vec::new(); + let _ = tokio::io::BufReader::new(stderr_handle) + .read_to_end(&mut buf) + .await; + buf + }; + + let ((stdout, stdout_truncated), stderr) = tokio::join!(stdout_fut, stderr_fut); + + if let Some(task) = stdin_task { + let _ = task.await; + } + + let status = child + .wait() + .await + .map_err(|e| LogicShellError::Dispatch(format!("wait failed: {e}")))?; + + let exit_code = status.code().unwrap_or(-1); + + Ok(DispatchOutput { + exit_code, + stdout, + stderr, + stdout_truncated, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::LimitsConfig; + + fn default_dispatcher() -> Dispatcher { + Dispatcher::new(&LimitsConfig::default()) + } + + /// Phase 5 smoke: Dispatcher is constructible — FR-01 + #[test] + fn dispatcher_new() { + let _d = default_dispatcher(); + } + + /// `with_capture_limit` sets the byte cap directly. + #[test] + fn dispatcher_with_capture_limit() { + let d = Dispatcher::with_capture_limit(512); + assert_eq!(d.max_stdout_capture_bytes, 512); + } + + /// Empty argv returns a Dispatch error — NFR-06 + #[tokio::test] + async fn empty_argv_returns_error() { + let d = default_dispatcher(); + let result = d + .dispatch(DispatchOptions { + argv: vec![], + ..Default::default() + }) + .await; + assert!(matches!(result, Err(LogicShellError::Dispatch(_)))); + } + + /// StdinMode variants are Clone + Debug — API completeness + #[test] + fn stdin_mode_clone_debug() { + let modes: &[StdinMode] = &[ + StdinMode::Null, + StdinMode::Inherit, + StdinMode::Piped(b"hi".to_vec()), + ]; + for m in modes { + let _ = format!("{m:?}"); + let _ = m.clone(); + } + } + + /// DispatchOptions default is well-formed. + #[test] + fn dispatch_options_default() { + let o = DispatchOptions::default(); + assert!(o.argv.is_empty()); + assert!(o.env_extra.is_empty()); + assert!(o.cwd.is_none()); + assert!(matches!(o.stdin, StdinMode::Null)); + } +} diff --git a/logicshell-core/src/lib.rs b/logicshell-core/src/lib.rs index 09266bb..d3e5188 100644 --- a/logicshell-core/src/lib.rs +++ b/logicshell-core/src/lib.rs @@ -1,34 +1,47 @@ // logicshell-core: dispatcher, config, safety, audit, hooks — no HTTP pub mod config; +pub mod dispatcher; pub mod error; pub use config::discovery::{discover, find_config_path}; pub use error::{LogicShellError, Result}; +use config::Config; +use dispatcher::{DispatchOptions, Dispatcher}; + /// Top-level façade that coordinates configuration, safety, dispatch, and audit. /// /// Hosts link this crate and call methods here; the TUI and CLI are thin layers /// over the same boundaries (Framework PRD §3.1, §11.1). pub struct LogicShell { - _private: (), + config: Config, } impl LogicShell { - /// Create a new `LogicShell` instance. - /// - /// Later phases will accept a validated `Config`; for now the constructor - /// is a zero-argument stub so the façade can be imported and tested. + /// Create a new `LogicShell` instance with built-in configuration defaults. pub fn new() -> Self { - Self { _private: () } + Self { + config: Config::default(), + } + } + + /// Create a `LogicShell` instance from a validated [`Config`]. + pub fn with_config(config: Config) -> Self { + Self { config } } - /// Stub: spawn and await a child process by argv. + /// Spawn a child process by argv and return its exit code — FR-01–04. /// - /// Full implementation: Phase 5 (Process dispatcher — FR-01–04). - pub async fn dispatch(&self, _argv: &[&str]) -> Result { - Err(LogicShellError::Dispatch( - "not yet implemented (phase 5)".into(), - )) + /// Uses `limits.max_stdout_capture_bytes` from the active config (NFR-08). + /// A nonzero exit code is returned as `Ok(n)`, not an error. + pub async fn dispatch(&self, argv: &[&str]) -> Result { + let d = Dispatcher::new(&self.config.limits); + let opts = DispatchOptions { + argv: argv.iter().map(|s| s.to_string()).collect(), + ..DispatchOptions::default() + }; + let output = d.dispatch(opts).await?; + Ok(output.exit_code) } /// Stub: evaluate a command through the safety policy engine. @@ -76,12 +89,31 @@ mod tests { let _ls = LogicShell::default(); } - /// Stub dispatch returns a `Dispatch` error, not a panic — NFR-06 + /// `with_config` constructs from an explicit config — Phase 5. + #[test] + fn facade_with_config() { + let cfg = Config::default(); + let _ls = LogicShell::with_config(cfg); + } + + /// Phase 5: dispatch runs a real command and returns its exit code — FR-01 + #[tokio::test] + async fn dispatch_runs_real_command() { + let ls = LogicShell::new(); + // `true` always exits 0 on Unix + let result = ls.dispatch(&["true"]).await; + assert!(result.is_ok(), "dispatch returned Err: {result:?}"); + assert_eq!(result.unwrap(), 0); + } + + /// Phase 5: dispatch propagates nonzero exit — FR-03 #[tokio::test] - async fn dispatch_stub_returns_error() { + async fn dispatch_propagates_nonzero_exit() { let ls = LogicShell::new(); - let result = ls.dispatch(&["ls"]).await; - assert!(matches!(result, Err(LogicShellError::Dispatch(_)))); + // `false` always exits 1 on Unix + let result = ls.dispatch(&["false"]).await; + assert!(result.is_ok(), "expected Ok(1), got Err: {result:?}"); + assert_eq!(result.unwrap(), 1); } /// Stub safety returns a `Safety` error, not a panic — NFR-06 diff --git a/logicshell-core/tests/dispatcher_integration.rs b/logicshell-core/tests/dispatcher_integration.rs new file mode 100644 index 0000000..e9122d4 --- /dev/null +++ b/logicshell-core/tests/dispatcher_integration.rs @@ -0,0 +1,292 @@ +// Integration tests for Phase 5 — Process dispatcher (FR-01–04, NFR-06, NFR-08) +// Uses CARGO_BIN_EXE_* fixture binaries built from src/bin/fixture_*.rs. + +use logicshell_core::dispatcher::{DispatchOptions, Dispatcher, StdinMode}; + +const ECHO_ARGV: &str = env!("CARGO_BIN_EXE_fixture_echo_argv"); +const ECHO_CWD: &str = env!("CARGO_BIN_EXE_fixture_echo_cwd"); +const ECHO_ENV: &str = env!("CARGO_BIN_EXE_fixture_echo_env"); +const EXIT_CODE: &str = env!("CARGO_BIN_EXE_fixture_exit_code"); +const FLOOD_STDOUT: &str = env!("CARGO_BIN_EXE_fixture_flood_stdout"); +const STDIN_ECHO: &str = env!("CARGO_BIN_EXE_fixture_stdin_echo"); + +fn default_dispatcher() -> Dispatcher { + Dispatcher::with_capture_limit(1_048_576) +} + +/// FR-01: argv is passed through to the child process unchanged. +#[tokio::test] +async fn argv_passthrough() { + let d = default_dispatcher(); + let out = d + .dispatch(DispatchOptions { + argv: vec![ECHO_ARGV.into(), "hello".into(), "world".into()], + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 0); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("hello"), "stdout: {stdout:?}"); + assert!(stdout.contains("world"), "stdout: {stdout:?}"); + assert!(!out.stdout_truncated); +} + +/// FR-01: a child with no extra args produces no output. +#[tokio::test] +async fn no_args_produces_empty_stdout() { + let d = default_dispatcher(); + let out = d + .dispatch(DispatchOptions { + argv: vec![ECHO_ARGV.into()], + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 0); + assert!(out.stdout.is_empty() || out.stdout == b"\n"); +} + +/// FR-03: nonzero exit code is propagated; no panic occurs. +#[tokio::test] +async fn nonzero_exit_does_not_panic() { + let d = default_dispatcher(); + let out = d + .dispatch(DispatchOptions { + argv: vec![EXIT_CODE.into(), "42".into()], + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 42); +} + +/// FR-03: exit code 1 is propagated correctly. +#[tokio::test] +async fn exit_code_one_propagated() { + let d = default_dispatcher(); + let out = d + .dispatch(DispatchOptions { + argv: vec![EXIT_CODE.into(), "1".into()], + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 1); +} + +/// FR-03: exit code 0 is propagated correctly. +#[tokio::test] +async fn exit_code_zero_propagated() { + let d = default_dispatcher(); + let out = d + .dispatch(DispatchOptions { + argv: vec![EXIT_CODE.into(), "0".into()], + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 0); +} + +/// FR-04: `cwd` is respected; the child sees the configured working directory. +#[tokio::test] +async fn cwd_respected() { + let d = default_dispatcher(); + let out = d + .dispatch(DispatchOptions { + argv: vec![ECHO_CWD.into()], + cwd: Some("/tmp".into()), + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 0); + let stdout = String::from_utf8_lossy(&out.stdout); + // /tmp may be canonicalized differently across distros; just verify it is + // non-empty and contains "tmp" so the cwd was applied. + assert!( + stdout.trim().contains("tmp"), + "expected cwd to be /tmp-ish, got: {stdout:?}" + ); +} + +/// FR-01: env vars from `env_extra` are visible to the child. +#[tokio::test] +async fn env_var_passthrough() { + let d = default_dispatcher(); + let out = d + .dispatch(DispatchOptions { + argv: vec![ECHO_ENV.into(), "LOGICSHELL_TEST_VAR".into()], + env_extra: vec![("LOGICSHELL_TEST_VAR".into(), "hello_env_value".into())], + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 0); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("hello_env_value"), "stdout: {stdout:?}"); +} + +/// FR-01: multiple env vars are all injected. +#[tokio::test] +async fn multiple_env_vars_injected() { + let d = default_dispatcher(); + let out = d + .dispatch(DispatchOptions { + argv: vec![ECHO_ENV.into(), "LS_VAR_B".into()], + env_extra: vec![ + ("LS_VAR_A".into(), "aaa".into()), + ("LS_VAR_B".into(), "bbb".into()), + ], + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 0); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("bbb"), "stdout: {stdout:?}"); +} + +/// NFR-08: stdout capture is truncated at the configured limit. +#[tokio::test] +async fn stdout_truncated_at_limit() { + let limit = 100u64; + let d = Dispatcher::with_capture_limit(limit); + let out = d + .dispatch(DispatchOptions { + argv: vec![FLOOD_STDOUT.into(), "1000000".into()], + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 0); + assert_eq!( + out.stdout.len() as u64, + limit, + "captured {} bytes, expected {limit}", + out.stdout.len() + ); + assert!(out.stdout_truncated, "expected stdout_truncated = true"); +} + +/// NFR-08: stdout is NOT truncated when output is within the limit. +#[tokio::test] +async fn stdout_not_truncated_when_within_limit() { + let d = Dispatcher::with_capture_limit(1_000_000); + let out = d + .dispatch(DispatchOptions { + argv: vec![FLOOD_STDOUT.into(), "200".into()], + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 0); + assert_eq!(out.stdout.len(), 200); + assert!(!out.stdout_truncated, "expected stdout_truncated = false"); +} + +/// NFR-08: zero capture limit means stdout is always truncated (edge case). +#[tokio::test] +async fn zero_capture_limit_truncates_all_stdout() { + let d = Dispatcher::with_capture_limit(0); + let out = d + .dispatch(DispatchOptions { + argv: vec![FLOOD_STDOUT.into(), "10".into()], + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 0); + assert!(out.stdout.is_empty()); + assert!(out.stdout_truncated); +} + +/// FR-02: `StdinMode::Piped` feeds bytes to the child's stdin. +#[tokio::test] +async fn stdin_piped_mode_feeds_data() { + let d = default_dispatcher(); + let out = d + .dispatch(DispatchOptions { + argv: vec![STDIN_ECHO.into()], + stdin: StdinMode::Piped(b"hello from stdin\n".to_vec()), + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 0); + assert_eq!(out.stdout, b"hello from stdin\n"); +} + +/// FR-02: `StdinMode::Null` gives the child an immediate EOF on stdin. +#[tokio::test] +async fn stdin_null_mode_gives_eof() { + let d = default_dispatcher(); + let out = d + .dispatch(DispatchOptions { + argv: vec![STDIN_ECHO.into()], + stdin: StdinMode::Null, + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(out.exit_code, 0); + assert!( + out.stdout.is_empty(), + "expected empty stdout for null stdin" + ); +} + +/// NFR-06: dispatching a nonexistent binary returns Err, not a panic. +#[tokio::test] +async fn nonexistent_binary_returns_error() { + let d = default_dispatcher(); + let result = d + .dispatch(DispatchOptions { + argv: vec!["/no/such/binary/logicshell_fixture_xyz".into()], + ..Default::default() + }) + .await; + + assert!( + result.is_err(), + "expected Err for nonexistent binary, got Ok" + ); + assert!( + matches!( + result.unwrap_err(), + logicshell_core::error::LogicShellError::Dispatch(_) + ), + "expected Dispatch error variant" + ); +} + +/// NFR-06: empty argv returns a structured Dispatch error. +#[tokio::test] +async fn empty_argv_returns_dispatch_error() { + let d = default_dispatcher(); + let result = d + .dispatch(DispatchOptions { + argv: vec![], + ..Default::default() + }) + .await; + + assert!(matches!( + result, + Err(logicshell_core::error::LogicShellError::Dispatch(_)) + )); +} From 0c4433e2fc0c0a2e8472741020deaa6f95b2f7fd Mon Sep 17 00:00:00 2001 From: Mehmet Acar Date: Sat, 18 Apr 2026 15:24:50 +0300 Subject: [PATCH 2/2] fix: exclude fixture binaries from tarpaulin and cover signal-kill path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixture binaries (src/bin/fixture_*.rs) are spawned as child processes during integration tests; tarpaulin cannot instrument them, so they counted as 0/26 covered lines, pulling overall coverage to 89.18%. Adding "*/bin/fixture_*.rs" to tarpaulin exclude-files restores coverage to ~95% (371/390 instrumented lines). Also adds a Unix-only unit test for the signal-kill exit-code path (status.code() == None → -1), covering the previously untested unwrap_or(-1) fallback in Dispatcher::dispatch. Co-Authored-By: Claude Sonnet 4.6 --- logicshell-core/src/dispatcher.rs | 16 ++++++++++++++++ tarpaulin.toml | 6 ++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/logicshell-core/src/dispatcher.rs b/logicshell-core/src/dispatcher.rs index 196fa98..79b5828 100644 --- a/logicshell-core/src/dispatcher.rs +++ b/logicshell-core/src/dispatcher.rs @@ -237,4 +237,20 @@ mod tests { assert!(o.cwd.is_none()); assert!(matches!(o.stdin, StdinMode::Null)); } + + /// FR-03: a process killed by signal has no exit code; we map it to -1. + #[cfg(unix)] + #[tokio::test] + async fn signal_killed_process_returns_minus_one() { + let d = default_dispatcher(); + // "kill -9 $$" sends SIGKILL to the shell itself → no exit code → -1. + let out = d + .dispatch(DispatchOptions { + argv: vec!["sh".into(), "-c".into(), "kill -9 $$".into()], + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(out.exit_code, -1); + } } diff --git a/tarpaulin.toml b/tarpaulin.toml index 2fa0a9f..a7de4c7 100644 --- a/tarpaulin.toml +++ b/tarpaulin.toml @@ -1,8 +1,10 @@ [default] workspace = true all-features = true -# Exclude thin binary shims and generated code from the coverage gate -exclude-files = ["*/main.rs"] +# Exclude thin binary shims, fixture binaries, and generated code from the coverage gate. +# Fixture binaries (fixture_*.rs) are spawned as child processes during integration tests; +# they are not instrumented by tarpaulin and would count as 0% coverage. +exclude-files = ["*/main.rs", "*/bin/fixture_*.rs"] # Fail CI if coverage drops below this threshold (Framework PRD §11.4) fail-under = 90 out = ["Html", "Xml"]