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::value::{VmError, VmValue};
7use crate::vm::Vm;
8
9thread_local! {
10    static VM_SOURCE_DIR: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
11}
12
13/// Set the source directory for the current thread (called by VM on file execution).
14pub(crate) fn set_thread_source_dir(dir: &std::path::Path) {
15    VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = Some(dir.to_path_buf()));
16}
17
18/// Reset thread-local process state (for test isolation).
19pub(crate) fn reset_process_state() {
20    VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
21}
22
23pub fn resolve_source_relative_path(path: &str) -> PathBuf {
24    let candidate = PathBuf::from(path);
25    if candidate.is_absolute() {
26        return candidate;
27    }
28    let base = VM_SOURCE_DIR
29        .with(|sd| sd.borrow().clone())
30        .or_else(|| std::env::current_dir().ok())
31        .unwrap_or_else(|| PathBuf::from("."));
32    base.join(candidate)
33}
34
35pub(crate) fn register_process_builtins(vm: &mut Vm) {
36    vm.register_builtin("env", |args, _out| {
37        let name = args.first().map(|a| a.display()).unwrap_or_default();
38        match std::env::var(&name) {
39            Ok(val) => Ok(VmValue::String(Rc::from(val))),
40            Err(_) => Ok(VmValue::Nil),
41        }
42    });
43
44    vm.register_builtin("timestamp", |_args, _out| {
45        use std::time::{SystemTime, UNIX_EPOCH};
46        let secs = SystemTime::now()
47            .duration_since(UNIX_EPOCH)
48            .map(|d| d.as_secs_f64())
49            .unwrap_or(0.0);
50        Ok(VmValue::Float(secs))
51    });
52
53    vm.register_builtin("exit", |args, _out| {
54        let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
55        std::process::exit(code as i32);
56    });
57
58    vm.register_builtin("exec", |args, _out| {
59        if args.is_empty() {
60            return Err(VmError::Thrown(VmValue::String(Rc::from(
61                "exec: command is required",
62            ))));
63        }
64        let cmd = args[0].display();
65        let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
66        let output = exec_command(None, &cmd, &cmd_args)
67            .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
68        Ok(vm_output_to_value(output))
69    });
70
71    vm.register_builtin("shell", |args, _out| {
72        let cmd = args.first().map(|a| a.display()).unwrap_or_default();
73        if cmd.is_empty() {
74            return Err(VmError::Thrown(VmValue::String(Rc::from(
75                "shell: command string is required",
76            ))));
77        }
78        let shell = if cfg!(target_os = "windows") {
79            "cmd"
80        } else {
81            "sh"
82        };
83        let flag = if cfg!(target_os = "windows") {
84            "/C"
85        } else {
86            "-c"
87        };
88        let output = exec_shell(None, shell, flag, &cmd)
89            .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
90        Ok(vm_output_to_value(output))
91    });
92
93    vm.register_builtin("exec_at", |args, _out| {
94        if args.len() < 2 {
95            return Err(VmError::Thrown(VmValue::String(Rc::from(
96                "exec_at: directory and command are required",
97            ))));
98        }
99        let dir = args[0].display();
100        let cmd = args[1].display();
101        let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
102        let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)
103            .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
104        Ok(vm_output_to_value(output))
105    });
106
107    vm.register_builtin("shell_at", |args, _out| {
108        if args.len() < 2 {
109            return Err(VmError::Thrown(VmValue::String(Rc::from(
110                "shell_at: directory and command string are required",
111            ))));
112        }
113        let dir = args[0].display();
114        let cmd = args[1].display();
115        if cmd.is_empty() {
116            return Err(VmError::Thrown(VmValue::String(Rc::from(
117                "shell_at: command string is required",
118            ))));
119        }
120        let shell = if cfg!(target_os = "windows") {
121            "cmd"
122        } else {
123            "sh"
124        };
125        let flag = if cfg!(target_os = "windows") {
126            "/C"
127        } else {
128            "-c"
129        };
130        let output = exec_shell(Some(dir.as_str()), shell, flag, &cmd)
131            .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
132        Ok(vm_output_to_value(output))
133    });
134
135    vm.register_builtin("elapsed", |_args, _out| {
136        static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
137        let start = START.get_or_init(std::time::Instant::now);
138        Ok(VmValue::Int(start.elapsed().as_millis() as i64))
139    });
140
141    // --- System attributes for prompt building ---
142
143    vm.register_builtin("username", |_args, _out| {
144        let user = std::env::var("USER")
145            .or_else(|_| std::env::var("USERNAME"))
146            .unwrap_or_default();
147        Ok(VmValue::String(Rc::from(user)))
148    });
149
150    vm.register_builtin("hostname", |_args, _out| {
151        let name = std::env::var("HOSTNAME")
152            .or_else(|_| std::env::var("COMPUTERNAME"))
153            .or_else(|_| {
154                std::process::Command::new("hostname")
155                    .output()
156                    .ok()
157                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
158                    .ok_or(std::env::VarError::NotPresent)
159            })
160            .unwrap_or_default();
161        Ok(VmValue::String(Rc::from(name)))
162    });
163
164    vm.register_builtin("platform", |_args, _out| {
165        let os = if cfg!(target_os = "macos") {
166            "darwin"
167        } else if cfg!(target_os = "linux") {
168            "linux"
169        } else if cfg!(target_os = "windows") {
170            "windows"
171        } else {
172            std::env::consts::OS
173        };
174        Ok(VmValue::String(Rc::from(os)))
175    });
176
177    vm.register_builtin("arch", |_args, _out| {
178        Ok(VmValue::String(Rc::from(std::env::consts::ARCH)))
179    });
180
181    vm.register_builtin("home_dir", |_args, _out| {
182        let home = std::env::var("HOME")
183            .or_else(|_| std::env::var("USERPROFILE"))
184            .unwrap_or_default();
185        Ok(VmValue::String(Rc::from(home)))
186    });
187
188    vm.register_builtin("pid", |_args, _out| {
189        Ok(VmValue::Int(std::process::id() as i64))
190    });
191
192    // --- Path / directory introspection ---
193
194    vm.register_builtin("date_iso", |_args, _out| {
195        use crate::stdlib::datetime::vm_civil_from_timestamp;
196        let now = std::time::SystemTime::now()
197            .duration_since(std::time::UNIX_EPOCH)
198            .unwrap_or_default();
199        let total_secs = now.as_secs();
200        let millis = now.subsec_millis();
201        let (y, m, d, hour, minute, second, _) = vm_civil_from_timestamp(total_secs);
202        Ok(VmValue::String(Rc::from(format!(
203            "{y:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z"
204        ))))
205    });
206
207    vm.register_builtin("cwd", |_args, _out| {
208        let dir = std::env::current_dir()
209            .map(|p| p.to_string_lossy().to_string())
210            .unwrap_or_default();
211        Ok(VmValue::String(Rc::from(dir)))
212    });
213}
214
215/// Find the project root by walking up from a base directory looking for harn.toml.
216pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
217    let mut dir = base.to_path_buf();
218    loop {
219        if dir.join("harn.toml").exists() {
220            return Some(dir);
221        }
222        if !dir.pop() {
223            return None;
224        }
225    }
226}
227
228/// Register builtins that depend on source directory context.
229pub(crate) fn register_path_builtins(vm: &mut Vm) {
230    vm.register_builtin("source_dir", |_args, _out| {
231        let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
232        match dir {
233            Some(d) => Ok(VmValue::String(Rc::from(d.to_string_lossy().to_string()))),
234            None => {
235                let cwd = std::env::current_dir()
236                    .map(|p| p.to_string_lossy().to_string())
237                    .unwrap_or_default();
238                Ok(VmValue::String(Rc::from(cwd)))
239            }
240        }
241    });
242
243    vm.register_builtin("project_root", |_args, _out| {
244        let base = VM_SOURCE_DIR
245            .with(|sd| sd.borrow().clone())
246            .or_else(|| std::env::current_dir().ok())
247            .unwrap_or_else(|| PathBuf::from("."));
248        match find_project_root(&base) {
249            Some(root) => Ok(VmValue::String(Rc::from(
250                root.to_string_lossy().to_string(),
251            ))),
252            None => Ok(VmValue::Nil),
253        }
254    });
255}
256
257fn vm_output_to_value(output: std::process::Output) -> VmValue {
258    let mut result = BTreeMap::new();
259    result.insert(
260        "stdout".to_string(),
261        VmValue::String(Rc::from(
262            String::from_utf8_lossy(&output.stdout).to_string().as_str(),
263        )),
264    );
265    result.insert(
266        "stderr".to_string(),
267        VmValue::String(Rc::from(
268            String::from_utf8_lossy(&output.stderr).to_string().as_str(),
269        )),
270    );
271    result.insert(
272        "status".to_string(),
273        VmValue::Int(output.status.code().unwrap_or(-1) as i64),
274    );
275    result.insert(
276        "success".to_string(),
277        VmValue::Bool(output.status.success()),
278    );
279    VmValue::Dict(Rc::new(result))
280}
281
282fn exec_command(
283    dir: Option<&str>,
284    cmd: &str,
285    args: &[String],
286) -> Result<std::process::Output, String> {
287    let mut command = std::process::Command::new(cmd);
288    command.args(args);
289    if let Some(dir) = dir {
290        command.current_dir(dir);
291    }
292    command.output().map_err(|e| format!("exec failed: {e}"))
293}
294
295fn exec_shell(
296    dir: Option<&str>,
297    shell: &str,
298    flag: &str,
299    script: &str,
300) -> Result<std::process::Output, String> {
301    let mut command = std::process::Command::new(shell);
302    command.arg(flag).arg(script);
303    if let Some(dir) = dir {
304        command.current_dir(dir);
305    }
306    command.output().map_err(|e| format!("shell failed: {e}"))
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn resolve_source_relative_path_prefers_thread_source_dir() {
315        let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
316        std::fs::create_dir_all(&dir).unwrap();
317        set_thread_source_dir(&dir);
318        let resolved = resolve_source_relative_path("templates/prompt.txt");
319        assert_eq!(resolved, dir.join("templates/prompt.txt"));
320        reset_process_state();
321        let _ = std::fs::remove_dir_all(&dir);
322    }
323}