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 execution_root_path() -> PathBuf {
35 current_execution_context()
36 .and_then(|context| context.cwd.map(PathBuf::from))
37 .or_else(|| std::env::current_dir().ok())
38 .unwrap_or_else(|| PathBuf::from("."))
39}
40
41pub fn source_root_path() -> PathBuf {
42 VM_SOURCE_DIR
43 .with(|sd| sd.borrow().clone())
44 .or_else(|| {
45 current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
46 })
47 .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
48 .or_else(|| std::env::current_dir().ok())
49 .unwrap_or_else(|| PathBuf::from("."))
50}
51
52pub fn asset_root_path() -> PathBuf {
53 source_root_path()
54}
55
56pub fn runtime_root_base() -> PathBuf {
57 find_project_root(&execution_root_path())
58 .or_else(|| find_project_root(&source_root_path()))
59 .unwrap_or_else(source_root_path)
60}
61
62pub fn resolve_source_relative_path(path: &str) -> PathBuf {
63 let candidate = PathBuf::from(path);
64 if candidate.is_absolute() {
65 return candidate;
66 }
67 execution_root_path().join(candidate)
68}
69
70pub fn resolve_source_asset_path(path: &str) -> PathBuf {
71 let candidate = PathBuf::from(path);
72 if candidate.is_absolute() {
73 return candidate;
74 }
75 asset_root_path().join(candidate)
76}
77
78pub(crate) fn register_process_builtins(vm: &mut Vm) {
79 vm.register_builtin("env", |args, _out| {
80 let name = args.first().map(|a| a.display()).unwrap_or_default();
81 if let Some(value) =
82 current_execution_context().and_then(|context| context.env.get(&name).cloned())
83 {
84 return Ok(VmValue::String(Rc::from(value)));
85 }
86 match std::env::var(&name) {
87 Ok(val) => Ok(VmValue::String(Rc::from(val))),
88 Err(_) => Ok(VmValue::Nil),
89 }
90 });
91
92 vm.register_builtin("timestamp", |_args, _out| {
93 use std::time::{SystemTime, UNIX_EPOCH};
94 let secs = SystemTime::now()
95 .duration_since(UNIX_EPOCH)
96 .map(|d| d.as_secs_f64())
97 .unwrap_or(0.0);
98 Ok(VmValue::Float(secs))
99 });
100
101 vm.register_builtin("exit", |args, _out| {
102 let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
103 std::process::exit(code as i32);
104 });
105
106 vm.register_builtin("exec", |args, _out| {
107 if args.is_empty() {
108 return Err(VmError::Thrown(VmValue::String(Rc::from(
109 "exec: command is required",
110 ))));
111 }
112 let cmd = args[0].display();
113 let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
114 let output = exec_command(None, &cmd, &cmd_args)
115 .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
116 Ok(vm_output_to_value(output))
117 });
118
119 vm.register_builtin("shell", |args, _out| {
120 let cmd = args.first().map(|a| a.display()).unwrap_or_default();
121 if cmd.is_empty() {
122 return Err(VmError::Thrown(VmValue::String(Rc::from(
123 "shell: command string is required",
124 ))));
125 }
126 let shell = if cfg!(target_os = "windows") {
127 "cmd"
128 } else {
129 "sh"
130 };
131 let flag = if cfg!(target_os = "windows") {
132 "/C"
133 } else {
134 "-c"
135 };
136 let output = exec_shell(None, shell, flag, &cmd)
137 .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
138 Ok(vm_output_to_value(output))
139 });
140
141 vm.register_builtin("exec_at", |args, _out| {
142 if args.len() < 2 {
143 return Err(VmError::Thrown(VmValue::String(Rc::from(
144 "exec_at: directory and command are required",
145 ))));
146 }
147 let dir = args[0].display();
148 let cmd = args[1].display();
149 let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
150 let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)
151 .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
152 Ok(vm_output_to_value(output))
153 });
154
155 vm.register_builtin("shell_at", |args, _out| {
156 if args.len() < 2 {
157 return Err(VmError::Thrown(VmValue::String(Rc::from(
158 "shell_at: directory and command string are required",
159 ))));
160 }
161 let dir = args[0].display();
162 let cmd = args[1].display();
163 if cmd.is_empty() {
164 return Err(VmError::Thrown(VmValue::String(Rc::from(
165 "shell_at: command string is required",
166 ))));
167 }
168 let shell = if cfg!(target_os = "windows") {
169 "cmd"
170 } else {
171 "sh"
172 };
173 let flag = if cfg!(target_os = "windows") {
174 "/C"
175 } else {
176 "-c"
177 };
178 let output = exec_shell(Some(dir.as_str()), shell, flag, &cmd)
179 .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
180 Ok(vm_output_to_value(output))
181 });
182
183 vm.register_builtin("elapsed", |_args, _out| {
184 static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
185 let start = START.get_or_init(std::time::Instant::now);
186 Ok(VmValue::Int(start.elapsed().as_millis() as i64))
187 });
188
189 vm.register_builtin("username", |_args, _out| {
190 let user = std::env::var("USER")
191 .or_else(|_| std::env::var("USERNAME"))
192 .unwrap_or_default();
193 Ok(VmValue::String(Rc::from(user)))
194 });
195
196 vm.register_builtin("hostname", |_args, _out| {
197 let name = std::env::var("HOSTNAME")
198 .or_else(|_| std::env::var("COMPUTERNAME"))
199 .or_else(|_| {
200 std::process::Command::new("hostname")
201 .output()
202 .ok()
203 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
204 .ok_or(std::env::VarError::NotPresent)
205 })
206 .unwrap_or_default();
207 Ok(VmValue::String(Rc::from(name)))
208 });
209
210 vm.register_builtin("platform", |_args, _out| {
211 let os = if cfg!(target_os = "macos") {
212 "darwin"
213 } else if cfg!(target_os = "linux") {
214 "linux"
215 } else if cfg!(target_os = "windows") {
216 "windows"
217 } else {
218 std::env::consts::OS
219 };
220 Ok(VmValue::String(Rc::from(os)))
221 });
222
223 vm.register_builtin("arch", |_args, _out| {
224 Ok(VmValue::String(Rc::from(std::env::consts::ARCH)))
225 });
226
227 vm.register_builtin("home_dir", |_args, _out| {
228 let home = std::env::var("HOME")
229 .or_else(|_| std::env::var("USERPROFILE"))
230 .unwrap_or_default();
231 Ok(VmValue::String(Rc::from(home)))
232 });
233
234 vm.register_builtin("pid", |_args, _out| {
235 Ok(VmValue::Int(std::process::id() as i64))
236 });
237
238 vm.register_builtin("date_iso", |_args, _out| {
239 use crate::stdlib::datetime::vm_civil_from_timestamp;
240 let now = std::time::SystemTime::now()
241 .duration_since(std::time::UNIX_EPOCH)
242 .unwrap_or_default();
243 let total_secs = now.as_secs();
244 let millis = now.subsec_millis();
245 let (y, m, d, hour, minute, second, _) = vm_civil_from_timestamp(total_secs);
246 Ok(VmValue::String(Rc::from(format!(
247 "{y:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z"
248 ))))
249 });
250
251 vm.register_builtin("cwd", |_args, _out| {
252 let dir = current_execution_context()
253 .and_then(|context| context.cwd)
254 .or_else(|| {
255 std::env::current_dir()
256 .ok()
257 .map(|p| p.to_string_lossy().into_owned())
258 })
259 .unwrap_or_default();
260 Ok(VmValue::String(Rc::from(dir)))
261 });
262
263 vm.register_builtin("execution_root", |_args, _out| {
264 Ok(VmValue::String(Rc::from(
265 execution_root_path().to_string_lossy().into_owned(),
266 )))
267 });
268
269 vm.register_builtin("asset_root", |_args, _out| {
270 Ok(VmValue::String(Rc::from(
271 asset_root_path().to_string_lossy().into_owned(),
272 )))
273 });
274
275 vm.register_builtin("runtime_paths", |_args, _out| {
276 let runtime_base = runtime_root_base();
277 let mut paths = BTreeMap::new();
278 paths.insert(
279 "execution_root".to_string(),
280 VmValue::String(Rc::from(
281 execution_root_path().to_string_lossy().into_owned(),
282 )),
283 );
284 paths.insert(
285 "asset_root".to_string(),
286 VmValue::String(Rc::from(asset_root_path().to_string_lossy().into_owned())),
287 );
288 paths.insert(
289 "state_root".to_string(),
290 VmValue::String(Rc::from(
291 crate::runtime_paths::state_root(&runtime_base)
292 .to_string_lossy()
293 .into_owned(),
294 )),
295 );
296 paths.insert(
297 "run_root".to_string(),
298 VmValue::String(Rc::from(
299 crate::runtime_paths::run_root(&runtime_base)
300 .to_string_lossy()
301 .into_owned(),
302 )),
303 );
304 paths.insert(
305 "worktree_root".to_string(),
306 VmValue::String(Rc::from(
307 crate::runtime_paths::worktree_root(&runtime_base)
308 .to_string_lossy()
309 .into_owned(),
310 )),
311 );
312 Ok(VmValue::Dict(Rc::new(paths)))
313 });
314}
315
316pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
318 let mut dir = base.to_path_buf();
319 loop {
320 if dir.join("harn.toml").exists() {
321 return Some(dir);
322 }
323 if !dir.pop() {
324 return None;
325 }
326 }
327}
328
329pub(crate) fn register_path_builtins(vm: &mut Vm) {
331 vm.register_builtin("source_dir", |_args, _out| {
332 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
333 match dir {
334 Some(d) => Ok(VmValue::String(Rc::from(d.to_string_lossy().into_owned()))),
335 None => {
336 let cwd = std::env::current_dir()
337 .map(|p| p.to_string_lossy().into_owned())
338 .unwrap_or_default();
339 Ok(VmValue::String(Rc::from(cwd)))
340 }
341 }
342 });
343
344 vm.register_builtin("project_root", |_args, _out| {
345 let base = current_execution_context()
346 .and_then(|context| context.cwd.map(PathBuf::from))
347 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
348 .or_else(|| std::env::current_dir().ok())
349 .unwrap_or_else(|| PathBuf::from("."));
350 match find_project_root(&base) {
351 Some(root) => Ok(VmValue::String(Rc::from(
352 root.to_string_lossy().into_owned(),
353 ))),
354 None => Ok(VmValue::Nil),
355 }
356 });
357}
358
359fn vm_output_to_value(output: std::process::Output) -> VmValue {
360 let mut result = BTreeMap::new();
361 result.insert(
362 "stdout".to_string(),
363 VmValue::String(Rc::from(
364 String::from_utf8_lossy(&output.stdout).to_string().as_str(),
365 )),
366 );
367 result.insert(
368 "stderr".to_string(),
369 VmValue::String(Rc::from(
370 String::from_utf8_lossy(&output.stderr).to_string().as_str(),
371 )),
372 );
373 result.insert(
374 "status".to_string(),
375 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
376 );
377 result.insert(
378 "success".to_string(),
379 VmValue::Bool(output.status.success()),
380 );
381 VmValue::Dict(Rc::new(result))
382}
383
384fn exec_command(
385 dir: Option<&str>,
386 cmd: &str,
387 args: &[String],
388) -> Result<std::process::Output, String> {
389 let mut command = std::process::Command::new(cmd);
390 command.args(args);
391 apply_execution_context(&mut command, dir);
392 command.output().map_err(|e| format!("exec failed: {e}"))
393}
394
395fn exec_shell(
396 dir: Option<&str>,
397 shell: &str,
398 flag: &str,
399 script: &str,
400) -> Result<std::process::Output, String> {
401 let mut command = std::process::Command::new(shell);
402 command.arg(flag).arg(script);
403 apply_execution_context(&mut command, dir);
404 command.output().map_err(|e| format!("shell failed: {e}"))
405}
406
407fn apply_execution_context(command: &mut std::process::Command, dir: Option<&str>) {
408 if let Some(dir) = dir {
409 command.current_dir(resolve_command_dir(dir));
410 } else if let Some(context) = current_execution_context() {
411 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
412 command.current_dir(cwd);
413 }
414 if !context.env.is_empty() {
415 command.envs(context.env);
416 }
417 }
418}
419
420fn resolve_command_dir(dir: &str) -> PathBuf {
421 let candidate = PathBuf::from(dir);
422 if candidate.is_absolute() {
423 return candidate;
424 }
425 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
426 return PathBuf::from(cwd).join(candidate);
427 }
428 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
429 return source_dir.join(candidate);
430 }
431 candidate
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
440 let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
441 std::fs::create_dir_all(&dir).unwrap();
442 let current_dir = std::env::current_dir().unwrap();
443 set_thread_source_dir(&dir);
444 let resolved = resolve_source_relative_path("templates/prompt.txt");
445 assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
446 reset_process_state();
447 let _ = std::fs::remove_dir_all(&dir);
448 }
449
450 #[test]
451 fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
452 let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
453 let source_dir =
454 std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
455 std::fs::create_dir_all(&cwd).unwrap();
456 std::fs::create_dir_all(&source_dir).unwrap();
457 set_thread_source_dir(&source_dir);
458 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
459 cwd: Some(cwd.to_string_lossy().into_owned()),
460 source_dir: Some(source_dir.to_string_lossy().into_owned()),
461 env: BTreeMap::new(),
462 adapter: None,
463 repo_path: None,
464 worktree_path: None,
465 branch: None,
466 base_ref: None,
467 cleanup: None,
468 }));
469 let resolved = resolve_source_relative_path("templates/prompt.txt");
470 assert_eq!(resolved, cwd.join("templates/prompt.txt"));
471 reset_process_state();
472 let _ = std::fs::remove_dir_all(&cwd);
473 let _ = std::fs::remove_dir_all(&source_dir);
474 }
475
476 #[test]
477 fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
478 let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
479 let source_dir =
480 std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
481 std::fs::create_dir_all(&cwd).unwrap();
482 std::fs::create_dir_all(&source_dir).unwrap();
483 set_thread_source_dir(&source_dir);
484 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
485 cwd: Some(cwd.to_string_lossy().into_owned()),
486 source_dir: Some(source_dir.to_string_lossy().into_owned()),
487 env: BTreeMap::new(),
488 adapter: None,
489 repo_path: None,
490 worktree_path: None,
491 branch: None,
492 base_ref: None,
493 cleanup: None,
494 }));
495 let resolved = resolve_source_asset_path("templates/prompt.txt");
496 assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
497 reset_process_state();
498 let _ = std::fs::remove_dir_all(&cwd);
499 let _ = std::fs::remove_dir_all(&source_dir);
500 }
501
502 #[test]
503 fn exec_context_sets_default_cwd_and_env() {
504 let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
505 std::fs::create_dir_all(&dir).unwrap();
506 std::fs::write(dir.join("marker.txt"), "ok").unwrap();
507 set_thread_execution_context(Some(RunExecutionRecord {
508 cwd: Some(dir.to_string_lossy().into_owned()),
509 env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
510 ..Default::default()
511 }));
512 let output = exec_shell(
513 None,
514 "sh",
515 "-c",
516 "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
517 )
518 .unwrap();
519 assert!(output.status.success());
520 assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
521 reset_process_state();
522 let _ = std::fs::remove_dir_all(&dir);
523 }
524
525 #[test]
526 fn exec_at_resolves_relative_to_execution_cwd() {
527 let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
528 std::fs::create_dir_all(dir.join("nested")).unwrap();
529 std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
530 set_thread_execution_context(Some(RunExecutionRecord {
531 cwd: Some(dir.to_string_lossy().into_owned()),
532 ..Default::default()
533 }));
534 let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
535 assert!(output.status.success());
536 reset_process_state();
537 let _ = std::fs::remove_dir_all(&dir);
538 }
539
540 #[test]
541 fn runtime_paths_uses_configurable_state_roots() {
542 let base =
543 std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
544 std::fs::create_dir_all(&base).unwrap();
545 std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
546 std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
547 std::env::set_var(
548 crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
549 ".custom-worktrees",
550 );
551 set_thread_execution_context(Some(RunExecutionRecord {
552 cwd: Some(base.to_string_lossy().into_owned()),
553 ..Default::default()
554 }));
555
556 let mut vm = crate::vm::Vm::new();
557 register_process_builtins(&mut vm);
558 let mut out = String::new();
559 let builtin = vm
560 .builtins
561 .get("runtime_paths")
562 .expect("runtime_paths builtin");
563 let paths = match builtin(&[], &mut out).unwrap() {
564 VmValue::Dict(map) => map,
565 other => panic!("expected dict, got {other:?}"),
566 };
567 assert_eq!(
568 paths.get("state_root").unwrap().display(),
569 base.join(".custom-harn").display().to_string()
570 );
571 assert_eq!(
572 paths.get("run_root").unwrap().display(),
573 base.join(".custom-runs").display().to_string()
574 );
575 assert_eq!(
576 paths.get("worktree_root").unwrap().display(),
577 base.join(".custom-worktrees").display().to_string()
578 );
579
580 reset_process_state();
581 std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV);
582 std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV);
583 std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV);
584 let _ = std::fs::remove_dir_all(&base);
585 }
586}