use std::time::Duration;
#[cfg(feature = "local")]
use std::time::Instant;
#[cfg(feature = "local")]
use tempfile::tempdir;
use theway_core::executor::ExecutorKind;
use theway_core::executor::{ExecutorError, ToolExecutor};
use theway_daemon::executor::local::LocalExecutor;
use theway_daemon::executor::sandbox::SandboxExecutor;
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| (*s).to_string()).collect()
}
#[cfg(feature = "local")]
#[tokio::test]
async fn write_read_list_round_trip() {
let dir = tempdir().unwrap();
let ex = LocalExecutor::with_cwd(dir.path());
ex.write_file(dir.path().join("b.txt").as_path(), "hello b")
.await
.unwrap();
ex.write_file(dir.path().join("nested/a.txt").as_path(), "hello a")
.await
.unwrap();
assert_eq!(
ex.read_file(&dir.path().join("b.txt")).await.unwrap(),
"hello b"
);
assert_eq!(
ex.read_file(&dir.path().join("nested/a.txt"))
.await
.unwrap(),
"hello a"
);
let names = ex.list_dir(dir.path()).await.unwrap();
assert_eq!(names, vec!["b.txt".to_string(), "nested".to_string()]);
ex.write_file(&dir.path().join("b.txt"), "replaced")
.await
.unwrap();
assert_eq!(
ex.read_file(&dir.path().join("b.txt")).await.unwrap(),
"replaced"
);
let err = ex
.read_file(&dir.path().join("nope.txt"))
.await
.unwrap_err();
assert!(matches!(err, ExecutorError::Other(_)));
}
#[cfg(feature = "local")]
#[tokio::test]
async fn run_command_captures_output_and_exit_code() {
let dir = tempdir().unwrap();
let ex = LocalExecutor::with_cwd(dir.path());
let out = ex
.run_command(dir.path(), &argv(&["echo", "ok"]), Duration::from_secs(10))
.await
.unwrap();
assert_eq!(out.stdout, "ok\n");
assert_eq!(out.stderr, "");
assert_eq!(out.exit_code, 0);
assert!(out.success());
let out = ex
.run_command(
dir.path(),
&argv(&["sh", "-c", "echo hi; echo err >&2; exit 3"]),
Duration::from_secs(10),
)
.await
.unwrap();
assert_eq!(out.stdout, "hi\n");
assert_eq!(out.stderr, "err\n");
assert_eq!(out.exit_code, 3);
assert!(!out.success());
}
#[cfg(feature = "local")]
#[tokio::test]
async fn run_command_respects_cwd() {
let dir = tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir(&sub).unwrap();
let ex = LocalExecutor::with_cwd(dir.path());
let out = ex
.run_command(&sub, &argv(&["pwd"]), Duration::from_secs(10))
.await
.unwrap();
assert_eq!(out.exit_code, 0);
let got = std::fs::canonicalize(out.stdout.trim()).unwrap();
let want = std::fs::canonicalize(&sub).unwrap();
assert_eq!(got, want);
}
#[cfg(feature = "local")]
#[tokio::test]
async fn run_command_timeout_kills_and_reports_minus_one() {
let dir = tempdir().unwrap();
let ex = LocalExecutor::with_cwd(dir.path());
let start = Instant::now();
let out = ex
.run_command(
dir.path(),
&argv(&["sleep", "5"]),
Duration::from_millis(100),
)
.await
.unwrap();
let elapsed = start.elapsed();
assert_eq!(out.exit_code, -1);
assert!(
elapsed < Duration::from_secs(3),
"timeout kill should return promptly, took {elapsed:?}"
);
}
#[cfg(feature = "local")]
#[tokio::test]
async fn run_command_empty_argv_is_an_error() {
let dir = tempdir().unwrap();
let ex = LocalExecutor::with_cwd(dir.path());
let err = ex
.run_command(dir.path(), &[], Duration::from_secs(1))
.await
.unwrap_err();
assert!(matches!(err, ExecutorError::Other(_)));
}
#[cfg(feature = "local")]
#[tokio::test]
async fn grep_returns_matching_lines() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "fn alpha() {}\nfn beta() {}\n").unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("sub/b.rs"), "fn gamma() {}\n").unwrap();
std::fs::write(dir.path().join("c.txt"), "fn delta() {}\n").unwrap();
let ex = LocalExecutor::with_cwd(dir.path());
let hits = ex.grep(r"fn \w+\(", dir.path()).await.unwrap();
assert_eq!(hits.len(), 4);
assert!(hits.iter().any(|h| h.contains("a.rs:1:fn alpha")));
assert!(hits.iter().any(|h| h.contains("a.rs:2:fn beta")));
assert!(hits.iter().any(|h| h.contains("b.rs:1:fn gamma")));
assert!(hits.iter().any(|h| h.contains("c.txt:1:fn delta")));
let err = ex.grep("(", dir.path()).await.unwrap_err();
assert!(matches!(err, ExecutorError::Other(_)));
}
#[cfg(feature = "local")]
#[tokio::test]
async fn find_matches_glob_files() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "").unwrap();
std::fs::write(dir.path().join("b.txt"), "").unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("sub/c.rs"), "").unwrap();
let ex = LocalExecutor::with_cwd(dir.path());
let hits = ex.find("*.rs", dir.path()).await.unwrap();
assert_eq!(hits.len(), 2);
assert!(hits.iter().any(|p| p.ends_with("a.rs")));
assert!(
hits.iter()
.any(|p| p.ends_with("sub/c.rs") || p.ends_with("sub\\c.rs"))
);
assert!(!hits.iter().any(|p| p.ends_with("b.txt")));
}
#[cfg(feature = "local")]
#[tokio::test]
async fn git_runs_in_repo_context() {
let dir = tempdir().unwrap();
let ex = LocalExecutor::with_cwd(dir.path());
let probe = ex
.run_command(
dir.path(),
&argv(&["git", "--version"]),
Duration::from_secs(10),
)
.await;
if probe.map(|o| o.exit_code != 0).unwrap_or(true) {
eprintln!("git not available; skipping git executor test");
return;
}
let init = ex
.git(&argv(&["init", "-q"]))
.await
.expect("git init through the executor");
assert_eq!(init.exit_code, 0, "git init failed: {}", init.stderr);
let out = ex
.git(&argv(&["rev-parse", "--is-inside-work-tree"]))
.await
.unwrap();
assert_eq!(out.exit_code, 0);
assert_eq!(out.stdout.trim(), "true");
let outside = tempdir().unwrap();
let ex2 = LocalExecutor::with_cwd(outside.path());
let out = ex2.git(&argv(&["status", "--porcelain"])).await.unwrap();
assert_ne!(out.exit_code, 0);
assert!(!out.stderr.is_empty());
}
#[tokio::test]
async fn kind_reports_local_and_sandbox() {
assert_eq!(LocalExecutor::new().kind().await, ExecutorKind::Local);
assert_eq!(SandboxExecutor::new().kind().await, ExecutorKind::Sandbox);
}
#[tokio::test]
async fn sandbox_all_operations_fail_fast_with_unsupported() {
let ex = SandboxExecutor::new();
let deadline = Duration::from_secs(1);
async fn assert_unsupported<T: std::fmt::Debug>(
label: &str,
fut: impl std::future::Future<Output = theway_core::executor::Result<T>>,
) {
let r = tokio::time::timeout(Duration::from_secs(1), fut).await;
match r {
Ok(Err(ExecutorError::UnsupportedKind(ExecutorKind::Sandbox))) => {}
other => panic!("{label}: expected prompt UnsupportedKind(Sandbox), got {other:?}"),
}
}
let path = std::path::Path::new("/nonexistent");
assert_unsupported("read_file", ex.read_file(path)).await;
assert_unsupported("write_file", ex.write_file(path, "x")).await;
assert_unsupported(
"run_command",
ex.run_command(path, &argv(&["true"]), deadline),
)
.await;
assert_unsupported("list_dir", ex.list_dir(path)).await;
assert_unsupported("grep", ex.grep("x", path)).await;
assert_unsupported("find", ex.find("*", path)).await;
assert_unsupported("git", ex.git(&argv(&["status"]))).await;
}
#[cfg(feature = "local")]
#[tokio::test]
async fn write_file_is_atomic_under_concurrency() {
let dir = tempdir().unwrap();
let ex = LocalExecutor::with_cwd(dir.path());
let path = dir.path().join("contended.txt");
const WRITERS: usize = 8;
const ROUNDS: usize = 20;
let contents: Vec<String> = (0..WRITERS)
.map(|i| format!("writer {i}: {}\n", "y".repeat(256 * 1024)))
.collect();
let mut writers = Vec::new();
for content in &contents {
let ex = ex.clone();
let path = path.clone();
let content = content.clone();
writers.push(tokio::spawn(async move {
for _ in 0..ROUNDS {
ex.write_file(&path, &content).await.unwrap();
}
}));
}
let path_read = path.clone();
let reader_known = contents.clone();
let reader = tokio::spawn(async move {
for _ in 0..200 {
if let Ok(body) = tokio::fs::read_to_string(&path_read).await {
assert!(
reader_known.contains(&body),
"partial/torn content observed: {} bytes",
body.len()
);
} tokio::task::yield_now().await;
}
});
for w in writers {
w.await.unwrap();
}
reader.await.unwrap();
let final_body = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains(&final_body), "final content is torn");
let litter: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains("theway-tmp"))
.collect();
assert!(litter.is_empty(), "temp litter: {litter:?}");
}
#[cfg(all(unix, feature = "local"))]
#[tokio::test]
async fn write_file_through_symlink_updates_target() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let ex = LocalExecutor::with_cwd(dir.path());
let target = dir.path().join("real.txt");
let link = dir.path().join("link.txt");
std::fs::write(&target, "old").unwrap();
symlink(&target, &link).unwrap();
ex.write_file(&link, "new").await.unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "new");
let meta = std::fs::symlink_metadata(&link).unwrap();
assert!(meta.file_type().is_symlink(), "link was replaced by a file");
}