theway_daemon/executor/
local.rs1use std::path::{Path, PathBuf};
28use std::process::Stdio;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::time::Duration;
31
32use async_trait::async_trait;
33use ignore::WalkBuilder;
34use regex::Regex;
35use theway_core::executor::{CommandOutput, ExecutorError, ExecutorKind, Result, ToolExecutor};
36
37const GIT_TIMEOUT: Duration = Duration::from_secs(60);
40
41const MAX_GREP_MATCHES: usize = 100;
44
45const MAX_GREP_FILES: usize = 5_000;
48
49const MAX_FIND_PATHS: usize = 200;
52
53static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
57
58async fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> {
69 let target = match tokio::fs::symlink_metadata(path).await {
70 Ok(meta) if meta.file_type().is_symlink() => match tokio::fs::canonicalize(path).await {
71 Ok(real) => real,
72 Err(_) => {
73 return tokio::fs::write(path, content).await;
74 }
75 },
76 _ => path.to_path_buf(),
77 };
78
79 let file_name = target.file_name().ok_or_else(|| {
80 std::io::Error::new(
81 std::io::ErrorKind::InvalidInput,
82 format!("path has no file name: {}", target.display()),
83 )
84 })?;
85 let tmp_path = target.with_file_name(format!(
86 ".{}.theway-tmp-{}-{}",
87 file_name.to_string_lossy(),
88 std::process::id(),
89 TMP_COUNTER.fetch_add(1, Ordering::Relaxed),
90 ));
91
92 tokio::fs::write(&tmp_path, content).await?;
93 if let Err(e) = tokio::fs::rename(&tmp_path, &target).await {
94 let _ = tokio::fs::remove_file(&tmp_path).await;
95 return Err(e);
96 }
97 Ok(())
98}
99
100#[derive(Debug, Clone)]
104pub struct LocalExecutor {
105 cwd: PathBuf,
108}
109
110impl Default for LocalExecutor {
111 fn default() -> Self {
112 Self::new()
113 }
114}
115
116impl LocalExecutor {
117 pub fn new() -> Self {
119 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
120 Self { cwd }
121 }
122
123 pub fn with_cwd(cwd: impl Into<PathBuf>) -> Self {
125 Self { cwd: cwd.into() }
126 }
127
128 pub fn cwd(&self) -> &Path {
131 &self.cwd
132 }
133
134 fn resolve(&self, path: &Path) -> PathBuf {
137 if path.is_absolute() {
138 path.to_path_buf()
139 } else {
140 self.cwd.join(path)
141 }
142 }
143}
144
145async fn spawn_and_wait(
152 program: &str,
153 args: &[String],
154 cwd: &Path,
155 timeout: Duration,
156) -> Result<CommandOutput> {
157 let mut cmd = tokio::process::Command::new(program);
158 cmd.args(args)
159 .current_dir(cwd)
160 .stdout(Stdio::piped())
161 .stderr(Stdio::piped())
162 .kill_on_drop(true);
163 let child = cmd
164 .spawn()
165 .map_err(|e| ExecutorError::Other(format!("spawn {program}: {e}")))?;
166
167 let wait = child.wait_with_output();
172 let output = tokio::select! {
173 r = wait => r.map_err(|e| ExecutorError::Other(format!("wait {program}: {e}")))?,
174 () = tokio::time::sleep(timeout) => {
175 return Ok(CommandOutput {
176 stdout: String::new(),
177 stderr: String::new(),
178 exit_code: -1,
179 });
180 }
181 };
182 Ok(CommandOutput {
183 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
184 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
185 exit_code: output.status.code().unwrap_or(-1),
186 })
187}
188
189#[async_trait]
190impl ToolExecutor for LocalExecutor {
191 async fn kind(&self) -> ExecutorKind {
192 ExecutorKind::Local
193 }
194
195 async fn read_file(&self, path: &Path) -> Result<String> {
196 let path = self.resolve(path);
197 tokio::fs::read_to_string(&path)
198 .await
199 .map_err(|e| ExecutorError::Other(format!("read {}: {e}", path.display())))
200 }
201
202 async fn write_file(&self, path: &Path, content: &str) -> Result<()> {
203 let path = self.resolve(path);
204 if let Some(parent) = path.parent()
206 && !parent.as_os_str().is_empty()
207 {
208 tokio::fs::create_dir_all(parent).await.map_err(|e| {
209 ExecutorError::Other(format!("create_dir_all {}: {e}", parent.display()))
210 })?;
211 }
212 atomic_write(&path, content.as_bytes())
213 .await
214 .map_err(|e| ExecutorError::Other(format!("write {}: {e}", path.display())))
215 }
216
217 async fn run_command(
218 &self,
219 cwd: &Path,
220 argv: &[String],
221 timeout: Duration,
222 ) -> Result<CommandOutput> {
223 let Some((program, args)) = argv.split_first() else {
224 return Err(ExecutorError::Other("run_command: empty argv".into()));
225 };
226 spawn_and_wait(program, args, &self.resolve(cwd), timeout).await
227 }
228
229 async fn list_dir(&self, path: &Path) -> Result<Vec<String>> {
230 let path = self.resolve(path);
231 let mut rd = tokio::fs::read_dir(&path)
232 .await
233 .map_err(|e| ExecutorError::Other(format!("list_dir {}: {e}", path.display())))?;
234 let mut names = Vec::new();
235 while let Some(entry) = rd
236 .next_entry()
237 .await
238 .map_err(|e| ExecutorError::Other(format!("list_dir {}: {e}", path.display())))?
239 {
240 names.push(entry.file_name().to_string_lossy().into_owned());
241 }
242 names.sort();
244 Ok(names)
245 }
246
247 async fn grep(&self, pattern: &str, path: &Path) -> Result<Vec<String>> {
248 let re = Regex::new(pattern)
249 .map_err(|e| ExecutorError::Other(format!("grep: invalid regex {pattern:?}: {e}")))?;
250 let path = self.resolve(path);
251 tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
254 let walker = WalkBuilder::new(&path)
255 .standard_filters(true)
256 .hidden(true)
257 .build();
258 let mut out = Vec::new();
259 let mut files_scanned = 0usize;
260 for entry in walker {
261 let Ok(entry) = entry else { continue };
262 if !entry.file_type().is_some_and(|t| t.is_file()) {
263 continue;
264 }
265 files_scanned += 1;
266 if files_scanned > MAX_GREP_FILES {
267 break;
268 }
269 let p = entry.path();
270 let Ok(body) = std::fs::read_to_string(p) else {
272 continue;
273 };
274 for (i, line) in body.lines().enumerate() {
275 if re.is_match(line) {
276 out.push(format!("{}:{}:{line}", p.display(), i + 1));
277 if out.len() >= MAX_GREP_MATCHES {
278 return Ok(out);
279 }
280 }
281 }
282 }
283 Ok(out)
284 })
285 .await
286 .map_err(|e| ExecutorError::Other(format!("grep: spawn_blocking: {e}")))?
287 }
288
289 async fn find(&self, glob: &str, path: &Path) -> Result<Vec<String>> {
290 let glob = glob.to_string();
291 let path = self.resolve(path);
292 tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
295 let mut tb = ignore::types::TypesBuilder::new();
296 tb.add("g", &glob)
297 .map_err(|e| ExecutorError::Other(format!("find: invalid glob {glob:?}: {e}")))?;
298 tb.select("g");
299 let types = tb
300 .build()
301 .map_err(|e| ExecutorError::Other(format!("find: invalid glob {glob:?}: {e}")))?;
302 let walker = WalkBuilder::new(&path)
303 .standard_filters(true)
304 .types(types)
305 .build();
306 let mut paths = Vec::new();
307 for entry in walker {
308 let Ok(entry) = entry else { continue };
309 if !entry.file_type().is_some_and(|t| t.is_file()) {
310 continue;
311 }
312 if paths.len() >= MAX_FIND_PATHS {
313 break;
314 }
315 paths.push(entry.path().display().to_string());
316 }
317 Ok(paths)
318 })
319 .await
320 .map_err(|e| ExecutorError::Other(format!("find: spawn_blocking: {e}")))?
321 }
322
323 async fn git(&self, args: &[String]) -> Result<CommandOutput> {
324 if args.is_empty() {
325 return Err(ExecutorError::Other("git: missing args".into()));
326 }
327 spawn_and_wait("git", args, &self.cwd, GIT_TIMEOUT).await
330 }
331}
332
333#[cfg(test)]
334tests_bridge_macro::tests_bridge!("executor/local");