use std::sync::{Arc, Mutex};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::Mutex as TokioMutex;
use tracing_subscriber::filter::LevelFilter;
fn make_test_analyzer() -> aptu_coder::CodeAnalyzer {
let peer = Arc::new(TokioMutex::new(None));
let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel::<aptu_coder::logging::LogEvent>();
let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
aptu_coder::CodeAnalyzer::new(
peer,
log_level_filter,
rx,
aptu_coder::metrics::MetricsSender(metrics_tx),
)
}
async fn call_exec_command_raw(params: serde_json::Value) -> serde_json::Value {
let analyzer = make_test_analyzer();
let (client, server) = tokio::io::duplex(65536);
let mut server_handle = tokio::spawn(async move {
let (server_rx, server_tx) = tokio::io::split(server);
if let Ok(service) = rmcp::serve_server(analyzer, (server_rx, server_tx)).await {
let _ = service.waiting().await;
}
});
let (client_rx, mut client_tx) = tokio::io::split(client);
let mut reader = BufReader::new(client_rx).lines();
let init = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "0.1.0"}
}
})
.to_string()
+ "\n";
client_tx.write_all(init.as_bytes()).await.unwrap();
client_tx.flush().await.unwrap();
let _resp = reader.next_line().await.unwrap().unwrap();
let notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": {}
})
.to_string()
+ "\n";
client_tx.write_all(notif.as_bytes()).await.unwrap();
client_tx.flush().await.unwrap();
let call = serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "exec_command",
"arguments": params
}
})
.to_string()
+ "\n";
client_tx.write_all(call.as_bytes()).await.unwrap();
client_tx.flush().await.unwrap();
tokio::select! {
result = async {
loop {
let line = reader.next_line().await.unwrap().unwrap();
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
if v.get("id") == Some(&serde_json::json!(2)) {
return v;
}
}
} => {
server_handle.abort();
result
}
outcome = &mut server_handle => {
match outcome {
Ok(_) => panic!("server task exited unexpectedly before tool response"),
Err(e) => panic!("server task panicked: {e}"),
}
}
}
}
#[tokio::test]
async fn exec_command_happy_path() {
let command = "echo hello";
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let status = child.wait().expect("should wait for child");
let exit_code = status.code();
assert_eq!(exit_code, Some(0), "exit code should be 0");
assert!(
stdout.contains("hello"),
"stdout should contain 'hello', got: {}",
stdout
);
}
#[tokio::test]
async fn exec_command_nonzero_exit() {
let command = "exit 42";
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let _stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let status = child.wait().expect("should wait for child");
let exit_code = status.code();
assert_eq!(exit_code, Some(42), "exit code should be 42");
}
#[tokio::test]
async fn exec_command_timeout() {
let command = "sleep 60";
let timeout_duration = std::time::Duration::from_millis(500);
let cmd = command.to_string();
let wait_result = tokio::time::timeout(
timeout_duration,
tokio::task::spawn_blocking(move || {
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(&cmd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let _stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
child.wait().ok()
}),
)
.await;
assert!(wait_result.is_err(), "timeout should occur");
}
#[tokio::test]
async fn exec_command_working_dir_rejection() {
let invalid_path = "/tmp";
assert!(
invalid_path.starts_with("/"),
"absolute paths should be rejected by validate_path"
);
}
#[tokio::test]
async fn exec_command_output_truncation() {
let command = "seq 1 3000";
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _status = child.wait().expect("should wait for child");
let line_count = stdout.lines().count();
assert!(
line_count > 2000,
"output should have >2000 lines, got: {}",
line_count
);
}
#[test]
fn test_truncate_output_by_lines() {
fn truncate_output(output: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
let lines: Vec<&str> = output.lines().collect();
let output_to_use = if lines.len() > max_lines {
lines[..max_lines].join("\n")
} else {
output.to_string()
};
if output_to_use.len() > max_bytes {
(output_to_use[..max_bytes].to_string(), true)
} else {
(output_to_use, lines.len() > max_lines)
}
}
let output = (1..=2500)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("\n");
let (truncated, was_truncated) = truncate_output(&output, 2000, 50 * 1024);
assert!(was_truncated, "should be truncated");
let line_count = truncated.lines().count();
assert_eq!(line_count, 2000, "should have exactly 2000 lines");
}
#[test]
fn test_truncate_output_by_bytes() {
fn truncate_output(output: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
let lines: Vec<&str> = output.lines().collect();
let output_to_use = if lines.len() > max_lines {
lines[..max_lines].join("\n")
} else {
output.to_string()
};
if output_to_use.len() > max_bytes {
(output_to_use[..max_bytes].to_string(), true)
} else {
(output_to_use, lines.len() > max_lines)
}
}
let output = "x".repeat(100 * 1024);
let (truncated, was_truncated) = truncate_output(&output, 2000, 50 * 1024);
assert!(was_truncated, "should be truncated");
assert!(
truncated.len() <= 50 * 1024,
"truncated output should not exceed 50KB"
);
}
#[tokio::test]
async fn test_handler_structured_output() {
let resp = call_exec_command_raw(serde_json::json!({"command": "echo hello"})).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "exit_code mismatch: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("hello"),
"stdout missing 'hello': {sc}"
);
assert!(
!sc["timed_out"].as_bool().unwrap_or(true),
"unexpected timed_out: {sc}"
);
}
#[tokio::test]
async fn test_handler_timeout_respected() {
let resp =
call_exec_command_raw(serde_json::json!({"command": "sleep 10", "timeout_secs": 1})).await;
let sc = &resp["result"]["structuredContent"];
assert!(
sc["timed_out"].as_bool().unwrap_or(false),
"expected timed_out=true: {sc}"
);
}
#[tokio::test]
async fn test_handler_invalid_working_dir() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hi",
"working_dir": "/nonexistent-absolute-path-for-test"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: {resp}"
);
}
#[tokio::test]
async fn test_handler_nonzero_exit() {
let resp = call_exec_command_raw(serde_json::json!({"command": "exit 42"})).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 42, "exit_code mismatch: {sc}");
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for non-zero exit: {resp}"
);
}
#[tokio::test]
async fn test_handler_timeout_partial_output() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo partial_output && sleep 10",
"timeout_secs": 1
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["timed_out"], true, "expected timed_out=true: {sc}");
let stdout = sc["stdout"].as_str().unwrap_or("");
assert!(
stdout.contains("partial_output"),
"expected partial_output in stdout on timeout, got: {stdout}"
);
}
#[tokio::test]
async fn test_handler_shell_preference() {
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("APTU_SHELL", "sh") };
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo $0"
}))
.await;
unsafe { std::env::remove_var("APTU_SHELL") };
let sc = &resp["result"]["structuredContent"];
let stdout = sc["stdout"].as_str().unwrap_or("");
assert!(
stdout.contains("sh"),
"expected sh in $0 output, got: {stdout}"
);
}
#[tokio::test]
async fn test_handler_stderr_populated() {
let resp = call_exec_command_raw(serde_json::json!({"command": "sh -c 'echo err >&2'"})).await;
let sc = &resp["result"]["structuredContent"];
assert!(
sc["stderr"].as_str().unwrap_or("").contains("err"),
"stderr missing 'err': {sc}"
);
}
#[tokio::test]
async fn test_handler_resource_limits_none_unchanged() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo test",
"memory_limit_mb": null,
"cpu_limit_secs": null
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "exit code should be 0: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("test"),
"stdout should contain 'test': {sc}"
);
assert!(
!sc["timed_out"].as_bool().unwrap_or(true),
"should not timeout: {sc}"
);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn test_handler_cpu_limit_kills_spin() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "sh -c 'while true; do :; done'",
"cpu_limit_secs": 1,
"timeout_secs": 10
}))
.await;
let sc = &resp["result"]["structuredContent"];
let exit_code = sc["exit_code"].as_i64();
assert!(
exit_code.is_none() || exit_code != Some(0),
"exit code should be null (signal kill) or non-zero: {sc}"
);
}
#[tokio::test]
async fn test_handler_memory_limit_accepted() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hello",
"memory_limit_mb": 512
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "exit code should be 0: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("hello"),
"stdout should contain 'hello': {sc}"
);
}
#[tokio::test]
async fn test_exec_command_large_stdout_no_deadlock() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "seq 1 500",
"timeout_secs": 10
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(
sc["timed_out"], false,
"large stdout must not trigger timeout: {sc}"
);
assert_eq!(sc["exit_code"], 0, "exit code should be 0: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("1"),
"stdout should contain output: {sc}"
);
}
#[tokio::test]
async fn test_exec_command_backgrounded_process() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo 'parent done'",
"timeout_secs": 5
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(
sc["timed_out"], false,
"normal command should not timeout: {sc}"
);
assert_eq!(
sc["output_truncated"], false,
"normal command should not truncate: {sc}"
);
assert!(
sc["stdout"].as_str().unwrap_or("").contains("parent done"),
"stdout should contain output: {sc}"
);
}
#[tokio::test]
async fn test_exec_command_overflow_to_temp_file() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "seq 1 3000",
"timeout_secs": 10
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["output_truncated"], true, "should be truncated: {sc}");
let stdout_path = sc["stdout_path"].as_str();
assert!(
stdout_path.is_some(),
"stdout_path should be set on overflow: {sc}"
);
assert!(
stdout_path.unwrap().contains("aptu-coder-overflow"),
"stdout_path should reference the overflow directory: {sc}"
);
assert!(
stdout_path.unwrap().contains("slot-"),
"stdout_path should contain slot identifier: {sc}"
);
}
#[tokio::test]
async fn test_exec_command_slot_isolation() {
let mut slot_ids = std::collections::HashSet::new();
for _ in 0..8 {
let resp = call_exec_command_raw(serde_json::json!({
"command": "seq 1 3000",
"timeout_secs": 10
}))
.await;
let sc = &resp["result"]["structuredContent"];
if let Some(path_str) = sc["stdout_path"].as_str() {
if let Some(slot_start) = path_str.find("slot-") {
let rest = &path_str[slot_start..];
let slot_end = rest.find('/').unwrap_or(rest.len());
let slot_id = &rest[..slot_end];
slot_ids.insert(slot_id.to_string());
}
}
}
assert!(
!slot_ids.is_empty(),
"should have extracted at least one slot identifier"
);
}
#[tokio::test]
async fn test_handler_interleaved_ordering() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo stdout_line && echo stderr_line >&2"
}))
.await;
let sc = &resp["result"]["structuredContent"];
let interleaved = sc["interleaved"].as_str().unwrap_or("");
assert!(
interleaved.contains("stdout_line"),
"interleaved missing stdout_line: {interleaved}"
);
assert!(
interleaved.contains("stderr_line"),
"interleaved missing stderr_line: {interleaved}"
);
assert!(
sc["stdout"].as_str().unwrap_or("").contains("stdout_line"),
"stdout field missing stdout_line: {sc}"
);
assert!(
sc["stderr"].as_str().unwrap_or("").contains("stderr_line"),
"stderr field missing stderr_line: {sc}"
);
}
#[test]
fn test_handler_output_collection_error() {
use aptu_coder_core::types::ShellOutput;
let mut output = ShellOutput::new(
"out".into(),
"err".into(),
"out\nerr\n".into(),
Some(0),
false,
false,
);
assert!(
output.output_collection_error.is_none(),
"output_collection_error must be None by default"
);
output.output_collection_error =
Some("post-exit drain timeout: background process held pipes".into());
assert!(
output.output_collection_error.is_some(),
"output_collection_error should be settable"
);
}
#[tokio::test]
async fn test_handler_content_priority() {
let resp = call_exec_command_raw(serde_json::json!({"command": "echo hello"})).await;
let content = &resp["result"]["content"];
let first = &content[0];
let priority = &first["annotations"]["priority"];
assert!(
!priority.is_null(),
"first content block should have annotations.priority: {first}"
);
let pval = priority.as_f64().unwrap_or(f64::NAN);
assert!(
(pval - 0.0).abs() < f64::EPSILON,
"priority should be 0.0, got: {pval}"
);
}
#[tokio::test]
async fn test_exec_cache_hit_on_sequential_repeat() {
let cmd = "echo cache_test_123";
let params1 = serde_json::json!({"command": cmd});
let params2 = serde_json::json!({"command": cmd});
let resp1 = call_exec_command_raw(params1).await;
let sc1 = &resp1["result"]["structuredContent"];
let stdout1 = sc1["stdout"].as_str().unwrap_or("").to_string();
let resp2 = call_exec_command_raw(params2).await;
let sc2 = &resp2["result"]["structuredContent"];
let stdout2 = sc2["stdout"].as_str().unwrap_or("").to_string();
assert_eq!(sc1["exit_code"], 0, "first call should succeed: {sc1}");
assert_eq!(sc2["exit_code"], 0, "second call should succeed: {sc2}");
assert_eq!(stdout1, stdout2, "cached output should match original");
assert!(
stdout1.contains("cache_test_123"),
"output should contain the echo string"
);
}
#[tokio::test]
async fn test_exec_cache_skipped_with_stdin() {
let cmd = "cat";
let stdin_content = "test_stdin_data";
let params = serde_json::json!({
"command": cmd,
"stdin": stdin_content
});
let resp = call_exec_command_raw(params).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "cat with stdin should succeed: {sc}");
assert!(
sc["stdout"]
.as_str()
.unwrap_or("")
.contains("test_stdin_data"),
"stdout should contain the stdin content: {sc}"
);
}
#[tokio::test]
async fn test_exec_cache_not_populated_on_failure() {
let cmd = "false";
let params1 = serde_json::json!({"command": cmd});
let params2 = serde_json::json!({"command": cmd});
let resp1 = call_exec_command_raw(params1).await;
let sc1 = &resp1["result"]["structuredContent"];
let resp2 = call_exec_command_raw(params2).await;
let sc2 = &resp2["result"]["structuredContent"];
assert_ne!(sc1["exit_code"], 0, "false command should fail: {sc1}");
assert_ne!(
sc2["exit_code"], 0,
"false command should fail on second call too: {sc2}"
);
}
#[tokio::test]
async fn test_exec_cache_bypassed_with_false_param() {
let cmd = "echo bypass_cache";
let params = serde_json::json!({
"command": cmd,
"cache": false
});
let resp = call_exec_command_raw(params).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "command should succeed: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("bypass_cache"),
"output should contain the echo string: {sc}"
);
}
#[tokio::test]
async fn test_exec_slot_files_not_written_for_small_output() {
let cmd = "echo slot_file_test";
let params = serde_json::json!({"command": cmd});
let resp = call_exec_command_raw(params).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(
sc["output_truncated"], false,
"small output must not be truncated: {sc}"
);
assert!(
sc["stdout_path"].is_null(),
"stdout_path must be absent for small output: {sc}"
);
assert!(
sc["stderr_path"].is_null(),
"stderr_path must be absent for small output: {sc}"
);
}