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(crate) 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 = VM_SOURCE_DIR
40        .with(|sd| sd.borrow().clone())
41        .or_else(|| std::env::current_dir().ok())
42        .unwrap_or_else(|| PathBuf::from("."));
43    base.join(candidate)
44}
45
46pub(crate) fn register_process_builtins(vm: &mut Vm) {
47    vm.register_builtin("env", |args, _out| {
48        let name = args.first().map(|a| a.display()).unwrap_or_default();
49        if let Some(value) =
50            current_execution_context().and_then(|context| context.env.get(&name).cloned())
51        {
52            return Ok(VmValue::String(Rc::from(value)));
53        }
54        match std::env::var(&name) {
55            Ok(val) => Ok(VmValue::String(Rc::from(val))),
56            Err(_) => Ok(VmValue::Nil),
57        }
58    });
59
60    vm.register_builtin("timestamp", |_args, _out| {
61        use std::time::{SystemTime, UNIX_EPOCH};
62        let secs = SystemTime::now()
63            .duration_since(UNIX_EPOCH)
64            .map(|d| d.as_secs_f64())
65            .unwrap_or(0.0);
66        Ok(VmValue::Float(secs))
67    });
68
69    vm.register_builtin("exit", |args, _out| {
70        let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
71        std::process::exit(code as i32);
72    });
73
74    vm.register_builtin("exec", |args, _out| {
75        if args.is_empty() {
76            return Err(VmError::Thrown(VmValue::String(Rc::from(
77                "exec: command is required",
78            ))));
79        }
80        let cmd = args[0].display();
81        let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
82        let output = exec_command(None, &cmd, &cmd_args)
83            .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
84        Ok(vm_output_to_value(output))
85    });
86
87    vm.register_builtin("shell", |args, _out| {
88        let cmd = args.first().map(|a| a.display()).unwrap_or_default();
89        if cmd.is_empty() {
90            return Err(VmError::Thrown(VmValue::String(Rc::from(
91                "shell: command string is required",
92            ))));
93        }
94        let shell = if cfg!(target_os = "windows") {
95            "cmd"
96        } else {
97            "sh"
98        };
99        let flag = if cfg!(target_os = "windows") {
100            "/C"
101        } else {
102            "-c"
103        };
104        let output = exec_shell(None, shell, flag, &cmd)
105            .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
106        Ok(vm_output_to_value(output))
107    });
108
109    vm.register_builtin("exec_at", |args, _out| {
110        if args.len() < 2 {
111            return Err(VmError::Thrown(VmValue::String(Rc::from(
112                "exec_at: directory and command are required",
113            ))));
114        }
115        let dir = args[0].display();
116        let cmd = args[1].display();
117        let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
118        let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)
119            .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
120        Ok(vm_output_to_value(output))
121    });
122
123    vm.register_builtin("shell_at", |args, _out| {
124        if args.len() < 2 {
125            return Err(VmError::Thrown(VmValue::String(Rc::from(
126                "shell_at: directory and command string are required",
127            ))));
128        }
129        let dir = args[0].display();
130        let cmd = args[1].display();
131        if cmd.is_empty() {
132            return Err(VmError::Thrown(VmValue::String(Rc::from(
133                "shell_at: command string is required",
134            ))));
135        }
136        let shell = if cfg!(target_os = "windows") {
137            "cmd"
138        } else {
139            "sh"
140        };
141        let flag = if cfg!(target_os = "windows") {
142            "/C"
143        } else {
144            "-c"
145        };
146        let output = exec_shell(Some(dir.as_str()), shell, flag, &cmd)
147            .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
148        Ok(vm_output_to_value(output))
149    });
150
151    vm.register_builtin("elapsed", |_args, _out| {
152        static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
153        let start = START.get_or_init(std::time::Instant::now);
154        Ok(VmValue::Int(start.elapsed().as_millis() as i64))
155    });
156
157    // --- System attributes for prompt building ---
158
159    vm.register_builtin("username", |_args, _out| {
160        let user = std::env::var("USER")
161            .or_else(|_| std::env::var("USERNAME"))
162            .unwrap_or_default();
163        Ok(VmValue::String(Rc::from(user)))
164    });
165
166    vm.register_builtin("hostname", |_args, _out| {
167        let name = std::env::var("HOSTNAME")
168            .or_else(|_| std::env::var("COMPUTERNAME"))
169            .or_else(|_| {
170                std::process::Command::new("hostname")
171                    .output()
172                    .ok()
173                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
174                    .ok_or(std::env::VarError::NotPresent)
175            })
176            .unwrap_or_default();
177        Ok(VmValue::String(Rc::from(name)))
178    });
179
180    vm.register_builtin("platform", |_args, _out| {
181        let os = if cfg!(target_os = "macos") {
182            "darwin"
183        } else if cfg!(target_os = "linux") {
184            "linux"
185        } else if cfg!(target_os = "windows") {
186            "windows"
187        } else {
188            std::env::consts::OS
189        };
190        Ok(VmValue::String(Rc::from(os)))
191    });
192
193    vm.register_builtin("arch", |_args, _out| {
194        Ok(VmValue::String(Rc::from(std::env::consts::ARCH)))
195    });
196
197    vm.register_builtin("home_dir", |_args, _out| {
198        let home = std::env::var("HOME")
199            .or_else(|_| std::env::var("USERPROFILE"))
200            .unwrap_or_default();
201        Ok(VmValue::String(Rc::from(home)))
202    });
203
204    vm.register_builtin("pid", |_args, _out| {
205        Ok(VmValue::Int(std::process::id() as i64))
206    });
207
208    // --- Path / directory introspection ---
209
210    vm.register_builtin("date_iso", |_args, _out| {
211        use crate::stdlib::datetime::vm_civil_from_timestamp;
212        let now = std::time::SystemTime::now()
213            .duration_since(std::time::UNIX_EPOCH)
214            .unwrap_or_default();
215        let total_secs = now.as_secs();
216        let millis = now.subsec_millis();
217        let (y, m, d, hour, minute, second, _) = vm_civil_from_timestamp(total_secs);
218        Ok(VmValue::String(Rc::from(format!(
219            "{y:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z"
220        ))))
221    });
222
223    vm.register_builtin("cwd", |_args, _out| {
224        let dir = current_execution_context()
225            .and_then(|context| context.cwd)
226            .or_else(|| {
227                std::env::current_dir()
228                    .ok()
229                    .map(|p| p.to_string_lossy().to_string())
230            })
231            .unwrap_or_default();
232        Ok(VmValue::String(Rc::from(dir)))
233    });
234}
235
236/// Find the project root by walking up from a base directory looking for harn.toml.
237pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
238    let mut dir = base.to_path_buf();
239    loop {
240        if dir.join("harn.toml").exists() {
241            return Some(dir);
242        }
243        if !dir.pop() {
244            return None;
245        }
246    }
247}
248
249/// Register builtins that depend on source directory context.
250pub(crate) fn register_path_builtins(vm: &mut Vm) {
251    vm.register_builtin("source_dir", |_args, _out| {
252        let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
253        match dir {
254            Some(d) => Ok(VmValue::String(Rc::from(d.to_string_lossy().to_string()))),
255            None => {
256                let cwd = std::env::current_dir()
257                    .map(|p| p.to_string_lossy().to_string())
258                    .unwrap_or_default();
259                Ok(VmValue::String(Rc::from(cwd)))
260            }
261        }
262    });
263
264    vm.register_builtin("project_root", |_args, _out| {
265        let base = current_execution_context()
266            .and_then(|context| context.cwd.map(PathBuf::from))
267            .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
268            .or_else(|| std::env::current_dir().ok())
269            .unwrap_or_else(|| PathBuf::from("."));
270        match find_project_root(&base) {
271            Some(root) => Ok(VmValue::String(Rc::from(
272                root.to_string_lossy().to_string(),
273            ))),
274            None => Ok(VmValue::Nil),
275        }
276    });
277}
278
279fn vm_output_to_value(output: std::process::Output) -> VmValue {
280    let mut result = BTreeMap::new();
281    result.insert(
282        "stdout".to_string(),
283        VmValue::String(Rc::from(
284            String::from_utf8_lossy(&output.stdout).to_string().as_str(),
285        )),
286    );
287    result.insert(
288        "stderr".to_string(),
289        VmValue::String(Rc::from(
290            String::from_utf8_lossy(&output.stderr).to_string().as_str(),
291        )),
292    );
293    result.insert(
294        "status".to_string(),
295        VmValue::Int(output.status.code().unwrap_or(-1) as i64),
296    );
297    result.insert(
298        "success".to_string(),
299        VmValue::Bool(output.status.success()),
300    );
301    VmValue::Dict(Rc::new(result))
302}
303
304fn exec_command(
305    dir: Option<&str>,
306    cmd: &str,
307    args: &[String],
308) -> Result<std::process::Output, String> {
309    let mut command = std::process::Command::new(cmd);
310    command.args(args);
311    apply_execution_context(&mut command, dir);
312    command.output().map_err(|e| format!("exec failed: {e}"))
313}
314
315fn exec_shell(
316    dir: Option<&str>,
317    shell: &str,
318    flag: &str,
319    script: &str,
320) -> Result<std::process::Output, String> {
321    let mut command = std::process::Command::new(shell);
322    command.arg(flag).arg(script);
323    apply_execution_context(&mut command, dir);
324    command.output().map_err(|e| format!("shell failed: {e}"))
325}
326
327fn apply_execution_context(command: &mut std::process::Command, dir: Option<&str>) {
328    if let Some(dir) = dir {
329        command.current_dir(resolve_command_dir(dir));
330    } else if let Some(context) = current_execution_context() {
331        if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
332            command.current_dir(cwd);
333        }
334        if !context.env.is_empty() {
335            command.envs(context.env);
336        }
337    }
338}
339
340fn resolve_command_dir(dir: &str) -> PathBuf {
341    let candidate = PathBuf::from(dir);
342    if candidate.is_absolute() {
343        return candidate;
344    }
345    if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
346        return PathBuf::from(cwd).join(candidate);
347    }
348    if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
349        return source_dir.join(candidate);
350    }
351    candidate
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn resolve_source_relative_path_prefers_thread_source_dir() {
360        let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
361        std::fs::create_dir_all(&dir).unwrap();
362        set_thread_source_dir(&dir);
363        let resolved = resolve_source_relative_path("templates/prompt.txt");
364        assert_eq!(resolved, dir.join("templates/prompt.txt"));
365        reset_process_state();
366        let _ = std::fs::remove_dir_all(&dir);
367    }
368
369    #[test]
370    fn exec_context_sets_default_cwd_and_env() {
371        let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
372        std::fs::create_dir_all(&dir).unwrap();
373        std::fs::write(dir.join("marker.txt"), "ok").unwrap();
374        set_thread_execution_context(Some(RunExecutionRecord {
375            cwd: Some(dir.to_string_lossy().to_string()),
376            env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
377            ..Default::default()
378        }));
379        let output = exec_shell(
380            None,
381            "sh",
382            "-c",
383            "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
384        )
385        .unwrap();
386        assert!(output.status.success());
387        assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
388        reset_process_state();
389        let _ = std::fs::remove_dir_all(&dir);
390    }
391
392    #[test]
393    fn exec_at_resolves_relative_to_execution_cwd() {
394        let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
395        std::fs::create_dir_all(dir.join("nested")).unwrap();
396        std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
397        set_thread_execution_context(Some(RunExecutionRecord {
398            cwd: Some(dir.to_string_lossy().to_string()),
399            ..Default::default()
400        }));
401        let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
402        assert!(output.status.success());
403        reset_process_state();
404        let _ = std::fs::remove_dir_all(&dir);
405    }
406}