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