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
15pub(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
28pub(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 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 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
236pub 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
249pub(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}