1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use std::rc::Rc;
5
6use crate::orchestration::RunExecutionRecord;
7use crate::value::{VmError, VmValue};
8use crate::vm::Vm;
9
10thread_local! {
11 pub(crate) static VM_SOURCE_DIR: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
12 static VM_EXECUTION_CONTEXT: RefCell<Option<RunExecutionRecord>> = const { RefCell::new(None) };
13}
14
15pub(crate) fn set_thread_source_dir(dir: &std::path::Path) {
17 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = Some(dir.to_path_buf()));
18}
19
20pub fn set_thread_execution_context(context: Option<RunExecutionRecord>) {
21 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = context);
22}
23
24pub(crate) fn current_execution_context() -> Option<RunExecutionRecord> {
25 VM_EXECUTION_CONTEXT.with(|current| current.borrow().clone())
26}
27
28pub(crate) fn reset_process_state() {
30 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
31 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
32}
33
34pub fn resolve_source_relative_path(path: &str) -> PathBuf {
35 let candidate = PathBuf::from(path);
36 if candidate.is_absolute() {
37 return candidate;
38 }
39 let base = current_execution_context()
40 .and_then(|context| context.cwd.map(PathBuf::from))
41 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
42 .or_else(|| std::env::current_dir().ok())
43 .unwrap_or_else(|| PathBuf::from("."));
44 base.join(candidate)
45}
46
47pub fn resolve_source_asset_path(path: &str) -> PathBuf {
48 let candidate = PathBuf::from(path);
49 if candidate.is_absolute() {
50 return candidate;
51 }
52 let base = VM_SOURCE_DIR
53 .with(|sd| sd.borrow().clone())
54 .or_else(|| {
55 current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
56 })
57 .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
58 .or_else(|| std::env::current_dir().ok())
59 .unwrap_or_else(|| PathBuf::from("."));
60 base.join(candidate)
61}
62
63pub(crate) fn register_process_builtins(vm: &mut Vm) {
64 vm.register_builtin("env", |args, _out| {
65 let name = args.first().map(|a| a.display()).unwrap_or_default();
66 if let Some(value) =
67 current_execution_context().and_then(|context| context.env.get(&name).cloned())
68 {
69 return Ok(VmValue::String(Rc::from(value)));
70 }
71 match std::env::var(&name) {
72 Ok(val) => Ok(VmValue::String(Rc::from(val))),
73 Err(_) => Ok(VmValue::Nil),
74 }
75 });
76
77 vm.register_builtin("timestamp", |_args, _out| {
78 use std::time::{SystemTime, UNIX_EPOCH};
79 let secs = SystemTime::now()
80 .duration_since(UNIX_EPOCH)
81 .map(|d| d.as_secs_f64())
82 .unwrap_or(0.0);
83 Ok(VmValue::Float(secs))
84 });
85
86 vm.register_builtin("exit", |args, _out| {
87 let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
88 std::process::exit(code as i32);
89 });
90
91 vm.register_builtin("exec", |args, _out| {
92 if args.is_empty() {
93 return Err(VmError::Thrown(VmValue::String(Rc::from(
94 "exec: command is required",
95 ))));
96 }
97 let cmd = args[0].display();
98 let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
99 let output = exec_command(None, &cmd, &cmd_args)
100 .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
101 Ok(vm_output_to_value(output))
102 });
103
104 vm.register_builtin("shell", |args, _out| {
105 let cmd = args.first().map(|a| a.display()).unwrap_or_default();
106 if cmd.is_empty() {
107 return Err(VmError::Thrown(VmValue::String(Rc::from(
108 "shell: command string is required",
109 ))));
110 }
111 let shell = if cfg!(target_os = "windows") {
112 "cmd"
113 } else {
114 "sh"
115 };
116 let flag = if cfg!(target_os = "windows") {
117 "/C"
118 } else {
119 "-c"
120 };
121 let output = exec_shell(None, shell, flag, &cmd)
122 .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
123 Ok(vm_output_to_value(output))
124 });
125
126 vm.register_builtin("exec_at", |args, _out| {
127 if args.len() < 2 {
128 return Err(VmError::Thrown(VmValue::String(Rc::from(
129 "exec_at: directory and command are required",
130 ))));
131 }
132 let dir = args[0].display();
133 let cmd = args[1].display();
134 let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
135 let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)
136 .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
137 Ok(vm_output_to_value(output))
138 });
139
140 vm.register_builtin("shell_at", |args, _out| {
141 if args.len() < 2 {
142 return Err(VmError::Thrown(VmValue::String(Rc::from(
143 "shell_at: directory and command string are required",
144 ))));
145 }
146 let dir = args[0].display();
147 let cmd = args[1].display();
148 if cmd.is_empty() {
149 return Err(VmError::Thrown(VmValue::String(Rc::from(
150 "shell_at: command string is required",
151 ))));
152 }
153 let shell = if cfg!(target_os = "windows") {
154 "cmd"
155 } else {
156 "sh"
157 };
158 let flag = if cfg!(target_os = "windows") {
159 "/C"
160 } else {
161 "-c"
162 };
163 let output = exec_shell(Some(dir.as_str()), shell, flag, &cmd)
164 .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
165 Ok(vm_output_to_value(output))
166 });
167
168 vm.register_builtin("elapsed", |_args, _out| {
169 static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
170 let start = START.get_or_init(std::time::Instant::now);
171 Ok(VmValue::Int(start.elapsed().as_millis() as i64))
172 });
173
174 vm.register_builtin("username", |_args, _out| {
177 let user = std::env::var("USER")
178 .or_else(|_| std::env::var("USERNAME"))
179 .unwrap_or_default();
180 Ok(VmValue::String(Rc::from(user)))
181 });
182
183 vm.register_builtin("hostname", |_args, _out| {
184 let name = std::env::var("HOSTNAME")
185 .or_else(|_| std::env::var("COMPUTERNAME"))
186 .or_else(|_| {
187 std::process::Command::new("hostname")
188 .output()
189 .ok()
190 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
191 .ok_or(std::env::VarError::NotPresent)
192 })
193 .unwrap_or_default();
194 Ok(VmValue::String(Rc::from(name)))
195 });
196
197 vm.register_builtin("platform", |_args, _out| {
198 let os = if cfg!(target_os = "macos") {
199 "darwin"
200 } else if cfg!(target_os = "linux") {
201 "linux"
202 } else if cfg!(target_os = "windows") {
203 "windows"
204 } else {
205 std::env::consts::OS
206 };
207 Ok(VmValue::String(Rc::from(os)))
208 });
209
210 vm.register_builtin("arch", |_args, _out| {
211 Ok(VmValue::String(Rc::from(std::env::consts::ARCH)))
212 });
213
214 vm.register_builtin("home_dir", |_args, _out| {
215 let home = std::env::var("HOME")
216 .or_else(|_| std::env::var("USERPROFILE"))
217 .unwrap_or_default();
218 Ok(VmValue::String(Rc::from(home)))
219 });
220
221 vm.register_builtin("pid", |_args, _out| {
222 Ok(VmValue::Int(std::process::id() as i64))
223 });
224
225 vm.register_builtin("date_iso", |_args, _out| {
228 use crate::stdlib::datetime::vm_civil_from_timestamp;
229 let now = std::time::SystemTime::now()
230 .duration_since(std::time::UNIX_EPOCH)
231 .unwrap_or_default();
232 let total_secs = now.as_secs();
233 let millis = now.subsec_millis();
234 let (y, m, d, hour, minute, second, _) = vm_civil_from_timestamp(total_secs);
235 Ok(VmValue::String(Rc::from(format!(
236 "{y:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z"
237 ))))
238 });
239
240 vm.register_builtin("cwd", |_args, _out| {
241 let dir = current_execution_context()
242 .and_then(|context| context.cwd)
243 .or_else(|| {
244 std::env::current_dir()
245 .ok()
246 .map(|p| p.to_string_lossy().to_string())
247 })
248 .unwrap_or_default();
249 Ok(VmValue::String(Rc::from(dir)))
250 });
251}
252
253pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
255 let mut dir = base.to_path_buf();
256 loop {
257 if dir.join("harn.toml").exists() {
258 return Some(dir);
259 }
260 if !dir.pop() {
261 return None;
262 }
263 }
264}
265
266pub(crate) fn register_path_builtins(vm: &mut Vm) {
268 vm.register_builtin("source_dir", |_args, _out| {
269 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
270 match dir {
271 Some(d) => Ok(VmValue::String(Rc::from(d.to_string_lossy().to_string()))),
272 None => {
273 let cwd = std::env::current_dir()
274 .map(|p| p.to_string_lossy().to_string())
275 .unwrap_or_default();
276 Ok(VmValue::String(Rc::from(cwd)))
277 }
278 }
279 });
280
281 vm.register_builtin("project_root", |_args, _out| {
282 let base = current_execution_context()
283 .and_then(|context| context.cwd.map(PathBuf::from))
284 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
285 .or_else(|| std::env::current_dir().ok())
286 .unwrap_or_else(|| PathBuf::from("."));
287 match find_project_root(&base) {
288 Some(root) => Ok(VmValue::String(Rc::from(
289 root.to_string_lossy().to_string(),
290 ))),
291 None => Ok(VmValue::Nil),
292 }
293 });
294}
295
296fn vm_output_to_value(output: std::process::Output) -> VmValue {
297 let mut result = BTreeMap::new();
298 result.insert(
299 "stdout".to_string(),
300 VmValue::String(Rc::from(
301 String::from_utf8_lossy(&output.stdout).to_string().as_str(),
302 )),
303 );
304 result.insert(
305 "stderr".to_string(),
306 VmValue::String(Rc::from(
307 String::from_utf8_lossy(&output.stderr).to_string().as_str(),
308 )),
309 );
310 result.insert(
311 "status".to_string(),
312 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
313 );
314 result.insert(
315 "success".to_string(),
316 VmValue::Bool(output.status.success()),
317 );
318 VmValue::Dict(Rc::new(result))
319}
320
321fn exec_command(
322 dir: Option<&str>,
323 cmd: &str,
324 args: &[String],
325) -> Result<std::process::Output, String> {
326 let mut command = std::process::Command::new(cmd);
327 command.args(args);
328 apply_execution_context(&mut command, dir);
329 command.output().map_err(|e| format!("exec failed: {e}"))
330}
331
332fn exec_shell(
333 dir: Option<&str>,
334 shell: &str,
335 flag: &str,
336 script: &str,
337) -> Result<std::process::Output, String> {
338 let mut command = std::process::Command::new(shell);
339 command.arg(flag).arg(script);
340 apply_execution_context(&mut command, dir);
341 command.output().map_err(|e| format!("shell failed: {e}"))
342}
343
344fn apply_execution_context(command: &mut std::process::Command, dir: Option<&str>) {
345 if let Some(dir) = dir {
346 command.current_dir(resolve_command_dir(dir));
347 } else if let Some(context) = current_execution_context() {
348 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
349 command.current_dir(cwd);
350 }
351 if !context.env.is_empty() {
352 command.envs(context.env);
353 }
354 }
355}
356
357fn resolve_command_dir(dir: &str) -> PathBuf {
358 let candidate = PathBuf::from(dir);
359 if candidate.is_absolute() {
360 return candidate;
361 }
362 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
363 return PathBuf::from(cwd).join(candidate);
364 }
365 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
366 return source_dir.join(candidate);
367 }
368 candidate
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn resolve_source_relative_path_prefers_thread_source_dir() {
377 let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
378 std::fs::create_dir_all(&dir).unwrap();
379 set_thread_source_dir(&dir);
380 let resolved = resolve_source_relative_path("templates/prompt.txt");
381 assert_eq!(resolved, dir.join("templates/prompt.txt"));
382 reset_process_state();
383 let _ = std::fs::remove_dir_all(&dir);
384 }
385
386 #[test]
387 fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
388 let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
389 let source_dir =
390 std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
391 std::fs::create_dir_all(&cwd).unwrap();
392 std::fs::create_dir_all(&source_dir).unwrap();
393 set_thread_source_dir(&source_dir);
394 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
395 cwd: Some(cwd.to_string_lossy().to_string()),
396 source_dir: Some(source_dir.to_string_lossy().to_string()),
397 env: BTreeMap::new(),
398 adapter: None,
399 repo_path: None,
400 worktree_path: None,
401 branch: None,
402 base_ref: None,
403 cleanup: None,
404 }));
405 let resolved = resolve_source_relative_path("templates/prompt.txt");
406 assert_eq!(resolved, cwd.join("templates/prompt.txt"));
407 reset_process_state();
408 let _ = std::fs::remove_dir_all(&cwd);
409 let _ = std::fs::remove_dir_all(&source_dir);
410 }
411
412 #[test]
413 fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
414 let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
415 let source_dir =
416 std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
417 std::fs::create_dir_all(&cwd).unwrap();
418 std::fs::create_dir_all(&source_dir).unwrap();
419 set_thread_source_dir(&source_dir);
420 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
421 cwd: Some(cwd.to_string_lossy().to_string()),
422 source_dir: Some(source_dir.to_string_lossy().to_string()),
423 env: BTreeMap::new(),
424 adapter: None,
425 repo_path: None,
426 worktree_path: None,
427 branch: None,
428 base_ref: None,
429 cleanup: None,
430 }));
431 let resolved = resolve_source_asset_path("templates/prompt.txt");
432 assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
433 reset_process_state();
434 let _ = std::fs::remove_dir_all(&cwd);
435 let _ = std::fs::remove_dir_all(&source_dir);
436 }
437
438 #[test]
439 fn exec_context_sets_default_cwd_and_env() {
440 let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
441 std::fs::create_dir_all(&dir).unwrap();
442 std::fs::write(dir.join("marker.txt"), "ok").unwrap();
443 set_thread_execution_context(Some(RunExecutionRecord {
444 cwd: Some(dir.to_string_lossy().to_string()),
445 env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
446 ..Default::default()
447 }));
448 let output = exec_shell(
449 None,
450 "sh",
451 "-c",
452 "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
453 )
454 .unwrap();
455 assert!(output.status.success());
456 assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
457 reset_process_state();
458 let _ = std::fs::remove_dir_all(&dir);
459 }
460
461 #[test]
462 fn exec_at_resolves_relative_to_execution_cwd() {
463 let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
464 std::fs::create_dir_all(dir.join("nested")).unwrap();
465 std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
466 set_thread_execution_context(Some(RunExecutionRecord {
467 cwd: Some(dir.to_string_lossy().to_string()),
468 ..Default::default()
469 }));
470 let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
471 assert!(output.status.success());
472 reset_process_state();
473 let _ = std::fs::remove_dir_all(&dir);
474 }
475}