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
13pub(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
18pub(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 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 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
215pub 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
228pub(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}