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 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    // --- System attributes for prompt building ---
175
176    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    // --- Path / directory introspection ---
226
227    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
253/// Find the project root by walking up from a base directory looking for harn.toml.
254pub 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
266/// Register builtins that depend on source directory context.
267pub(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}