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