Skip to main content

harn_vm/stdlib/
process.rs

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
15/// Set the source directory for the current thread (called by VM on file execution).
16pub(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
28/// Reset thread-local process state (for test isolation).
29pub(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
316/// Find the project root by walking up from a base directory looking for harn.toml.
317pub 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
329/// Register builtins that depend on source directory context.
330pub(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}