use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use ignore::WalkBuilder;
use regex::Regex;
use theway_core::executor::{CommandOutput, ExecutorError, ExecutorKind, Result, ToolExecutor};
const GIT_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_GREP_MATCHES: usize = 100;
const MAX_GREP_FILES: usize = 5_000;
const MAX_FIND_PATHS: usize = 200;
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
async fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> {
let target = match tokio::fs::symlink_metadata(path).await {
Ok(meta) if meta.file_type().is_symlink() => match tokio::fs::canonicalize(path).await {
Ok(real) => real,
Err(_) => {
return tokio::fs::write(path, content).await;
}
},
_ => path.to_path_buf(),
};
let file_name = target.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("path has no file name: {}", target.display()),
)
})?;
let tmp_path = target.with_file_name(format!(
".{}.theway-tmp-{}-{}",
file_name.to_string_lossy(),
std::process::id(),
TMP_COUNTER.fetch_add(1, Ordering::Relaxed),
));
tokio::fs::write(&tmp_path, content).await?;
if let Err(e) = tokio::fs::rename(&tmp_path, &target).await {
let _ = tokio::fs::remove_file(&tmp_path).await;
return Err(e);
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct LocalExecutor {
cwd: PathBuf,
}
impl Default for LocalExecutor {
fn default() -> Self {
Self::new()
}
}
impl LocalExecutor {
pub fn new() -> Self {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Self { cwd }
}
pub fn with_cwd(cwd: impl Into<PathBuf>) -> Self {
Self { cwd: cwd.into() }
}
pub fn cwd(&self) -> &Path {
&self.cwd
}
fn resolve(&self, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
self.cwd.join(path)
}
}
}
async fn spawn_and_wait(
program: &str,
args: &[String],
cwd: &Path,
timeout: Duration,
) -> Result<CommandOutput> {
let mut cmd = tokio::process::Command::new(program);
cmd.args(args)
.current_dir(cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let child = cmd
.spawn()
.map_err(|e| ExecutorError::Other(format!("spawn {program}: {e}")))?;
let wait = child.wait_with_output();
let output = tokio::select! {
r = wait => r.map_err(|e| ExecutorError::Other(format!("wait {program}: {e}")))?,
() = tokio::time::sleep(timeout) => {
return Ok(CommandOutput {
stdout: String::new(),
stderr: String::new(),
exit_code: -1,
});
}
};
Ok(CommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
exit_code: output.status.code().unwrap_or(-1),
})
}
#[async_trait]
impl ToolExecutor for LocalExecutor {
async fn kind(&self) -> ExecutorKind {
ExecutorKind::Local
}
async fn read_file(&self, path: &Path) -> Result<String> {
let path = self.resolve(path);
tokio::fs::read_to_string(&path)
.await
.map_err(|e| ExecutorError::Other(format!("read {}: {e}", path.display())))
}
async fn write_file(&self, path: &Path, content: &str) -> Result<()> {
let path = self.resolve(path);
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
tokio::fs::create_dir_all(parent).await.map_err(|e| {
ExecutorError::Other(format!("create_dir_all {}: {e}", parent.display()))
})?;
}
atomic_write(&path, content.as_bytes())
.await
.map_err(|e| ExecutorError::Other(format!("write {}: {e}", path.display())))
}
async fn run_command(
&self,
cwd: &Path,
argv: &[String],
timeout: Duration,
) -> Result<CommandOutput> {
let Some((program, args)) = argv.split_first() else {
return Err(ExecutorError::Other("run_command: empty argv".into()));
};
spawn_and_wait(program, args, &self.resolve(cwd), timeout).await
}
async fn list_dir(&self, path: &Path) -> Result<Vec<String>> {
let path = self.resolve(path);
let mut rd = tokio::fs::read_dir(&path)
.await
.map_err(|e| ExecutorError::Other(format!("list_dir {}: {e}", path.display())))?;
let mut names = Vec::new();
while let Some(entry) = rd
.next_entry()
.await
.map_err(|e| ExecutorError::Other(format!("list_dir {}: {e}", path.display())))?
{
names.push(entry.file_name().to_string_lossy().into_owned());
}
names.sort();
Ok(names)
}
async fn grep(&self, pattern: &str, path: &Path) -> Result<Vec<String>> {
let re = Regex::new(pattern)
.map_err(|e| ExecutorError::Other(format!("grep: invalid regex {pattern:?}: {e}")))?;
let path = self.resolve(path);
tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
let walker = WalkBuilder::new(&path)
.standard_filters(true)
.hidden(true)
.build();
let mut out = Vec::new();
let mut files_scanned = 0usize;
for entry in walker {
let Ok(entry) = entry else { continue };
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
files_scanned += 1;
if files_scanned > MAX_GREP_FILES {
break;
}
let p = entry.path();
let Ok(body) = std::fs::read_to_string(p) else {
continue;
};
for (i, line) in body.lines().enumerate() {
if re.is_match(line) {
out.push(format!("{}:{}:{line}", p.display(), i + 1));
if out.len() >= MAX_GREP_MATCHES {
return Ok(out);
}
}
}
}
Ok(out)
})
.await
.map_err(|e| ExecutorError::Other(format!("grep: spawn_blocking: {e}")))?
}
async fn find(&self, glob: &str, path: &Path) -> Result<Vec<String>> {
let glob = glob.to_string();
let path = self.resolve(path);
tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
let mut tb = ignore::types::TypesBuilder::new();
tb.add("g", &glob)
.map_err(|e| ExecutorError::Other(format!("find: invalid glob {glob:?}: {e}")))?;
tb.select("g");
let types = tb
.build()
.map_err(|e| ExecutorError::Other(format!("find: invalid glob {glob:?}: {e}")))?;
let walker = WalkBuilder::new(&path)
.standard_filters(true)
.types(types)
.build();
let mut paths = Vec::new();
for entry in walker {
let Ok(entry) = entry else { continue };
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
if paths.len() >= MAX_FIND_PATHS {
break;
}
paths.push(entry.path().display().to_string());
}
Ok(paths)
})
.await
.map_err(|e| ExecutorError::Other(format!("find: spawn_blocking: {e}")))?
}
async fn git(&self, args: &[String]) -> Result<CommandOutput> {
if args.is_empty() {
return Err(ExecutorError::Other("git: missing args".into()));
}
spawn_and_wait("git", args, &self.cwd, GIT_TIMEOUT).await
}
}
#[cfg(test)]
tests_bridge_macro::tests_bridge!("executor/local");