1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::process::Stdio;
4use std::rc::Rc;
5
6use crate::value::{values_equal, VmError, VmValue};
7use crate::vm::Vm;
8
9#[derive(Clone)]
10struct HostMock {
11 capability: String,
12 operation: String,
13 params: Option<BTreeMap<String, VmValue>>,
14 result: Option<VmValue>,
15 error: Option<String>,
16}
17
18#[derive(Clone)]
19struct HostMockCall {
20 capability: String,
21 operation: String,
22 params: BTreeMap<String, VmValue>,
23}
24
25thread_local! {
26 static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
27 static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
28}
29
30pub(crate) fn reset_host_state() {
31 HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
32 HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
33}
34
35fn capability_manifest_map() -> BTreeMap<String, VmValue> {
36 let mut root = BTreeMap::new();
37 root.insert(
38 "process".to_string(),
39 capability(
40 "Process execution.",
41 &[op("exec", "Execute a shell command.")],
42 ),
43 );
44 root.insert(
45 "template".to_string(),
46 capability(
47 "Template rendering.",
48 &[op("render", "Render a template file.")],
49 ),
50 );
51 root.insert(
52 "interaction".to_string(),
53 capability(
54 "User interaction.",
55 &[op("ask", "Ask the user a question.")],
56 ),
57 );
58 root
59}
60
61fn mocked_operation_entry() -> VmValue {
62 op(
63 "mocked",
64 "Mocked host operation registered at runtime for tests.",
65 )
66 .1
67}
68
69fn ensure_mocked_capability(
70 root: &mut BTreeMap<String, VmValue>,
71 capability_name: &str,
72 operation_name: &str,
73) {
74 let Some(existing) = root.get(capability_name).cloned() else {
75 root.insert(
76 capability_name.to_string(),
77 capability(
78 "Mocked host capability registered at runtime for tests.",
79 &[(operation_name.to_string(), mocked_operation_entry())],
80 ),
81 );
82 return;
83 };
84
85 let Some(existing_dict) = existing.as_dict() else {
86 return;
87 };
88 let mut entry = (*existing_dict).clone();
89 let mut ops = entry
90 .get("ops")
91 .and_then(|value| match value {
92 VmValue::List(list) => Some((**list).clone()),
93 _ => None,
94 })
95 .unwrap_or_default();
96 if !ops.iter().any(|value| value.display() == operation_name) {
97 ops.push(VmValue::String(Rc::from(operation_name.to_string())));
98 }
99
100 let mut operations = entry
101 .get("operations")
102 .and_then(|value| value.as_dict())
103 .map(|dict| (*dict).clone())
104 .unwrap_or_default();
105 operations
106 .entry(operation_name.to_string())
107 .or_insert_with(mocked_operation_entry);
108
109 entry.insert("ops".to_string(), VmValue::List(Rc::new(ops)));
110 entry.insert("operations".to_string(), VmValue::Dict(Rc::new(operations)));
111 root.insert(capability_name.to_string(), VmValue::Dict(Rc::new(entry)));
112}
113
114fn capability_manifest_with_mocks() -> VmValue {
115 let mut root = capability_manifest_map();
116 HOST_MOCKS.with(|mocks| {
117 for host_mock in mocks.borrow().iter() {
118 ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
119 }
120 });
121 VmValue::Dict(Rc::new(root))
122}
123
124fn op(name: &str, description: &str) -> (String, VmValue) {
125 let mut entry = BTreeMap::new();
126 entry.insert(
127 "description".to_string(),
128 VmValue::String(Rc::from(description)),
129 );
130 (name.to_string(), VmValue::Dict(Rc::new(entry)))
131}
132
133fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
134 let mut entry = BTreeMap::new();
135 entry.insert(
136 "description".to_string(),
137 VmValue::String(Rc::from(description)),
138 );
139 entry.insert(
140 "ops".to_string(),
141 VmValue::List(Rc::new(
142 ops.iter()
143 .map(|(name, _)| VmValue::String(Rc::from(name.as_str())))
144 .collect(),
145 )),
146 );
147 let mut op_dict = BTreeMap::new();
148 for (name, op) in ops {
149 op_dict.insert(name.clone(), op.clone());
150 }
151 entry.insert("operations".to_string(), VmValue::Dict(Rc::new(op_dict)));
152 VmValue::Dict(Rc::new(entry))
153}
154
155fn require_param(params: &BTreeMap<String, VmValue>, key: &str) -> Result<String, VmError> {
156 params
157 .get(key)
158 .map(|v| v.display())
159 .filter(|v| !v.is_empty())
160 .ok_or_else(|| {
161 VmError::Thrown(VmValue::String(Rc::from(format!(
162 "host_call: missing required parameter '{key}'"
163 ))))
164 })
165}
166
167fn render_template(
168 path: &str,
169 bindings: Option<&BTreeMap<String, VmValue>>,
170) -> Result<String, VmError> {
171 let resolved = crate::stdlib::process::resolve_source_asset_path(path);
172 let template = std::fs::read_to_string(&resolved).map_err(|e| {
173 VmError::Thrown(VmValue::String(Rc::from(format!(
174 "host_call template.render: failed to read template {}: {e}",
175 resolved.display()
176 ))))
177 })?;
178 let base = resolved.parent();
179 crate::stdlib::template::render_template_result(&template, bindings, base, Some(&resolved))
180 .map_err(VmError::from)
181}
182
183fn params_match(
184 expected: Option<&BTreeMap<String, VmValue>>,
185 actual: &BTreeMap<String, VmValue>,
186) -> bool {
187 let Some(expected) = expected else {
188 return true;
189 };
190 expected.iter().all(|(key, value)| {
191 actual
192 .get(key)
193 .is_some_and(|candidate| values_equal(candidate, value))
194 })
195}
196
197fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
198 let capability = args
199 .first()
200 .map(|value| value.display())
201 .unwrap_or_default();
202 let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
203 if capability.is_empty() || operation.is_empty() {
204 return Err(VmError::Thrown(VmValue::String(Rc::from(
205 "host_mock: capability and operation are required",
206 ))));
207 }
208
209 let mut params = args
210 .get(3)
211 .and_then(|value| value.as_dict())
212 .map(|dict| (*dict).clone());
213 let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
214 let mut error = None;
215
216 if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
217 if config.contains_key("result")
218 || config.contains_key("params")
219 || config.contains_key("error")
220 {
221 params = config
222 .get("params")
223 .and_then(|value| value.as_dict())
224 .map(|dict| (*dict).clone());
225 result = config.get("result").cloned();
226 error = config
227 .get("error")
228 .map(|value| value.display())
229 .filter(|value| !value.is_empty());
230 }
231 }
232
233 Ok(HostMock {
234 capability,
235 operation,
236 params,
237 result,
238 error,
239 })
240}
241
242fn push_host_mock(host_mock: HostMock) {
243 HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
244}
245
246fn mock_call_value(call: &HostMockCall) -> VmValue {
247 let mut item = BTreeMap::new();
248 item.insert(
249 "capability".to_string(),
250 VmValue::String(Rc::from(call.capability.clone())),
251 );
252 item.insert(
253 "operation".to_string(),
254 VmValue::String(Rc::from(call.operation.clone())),
255 );
256 item.insert(
257 "params".to_string(),
258 VmValue::Dict(Rc::new(call.params.clone())),
259 );
260 VmValue::Dict(Rc::new(item))
261}
262
263fn record_mock_call(capability: &str, operation: &str, params: &BTreeMap<String, VmValue>) {
264 HOST_MOCK_CALLS.with(|calls| {
265 calls.borrow_mut().push(HostMockCall {
266 capability: capability.to_string(),
267 operation: operation.to_string(),
268 params: params.clone(),
269 });
270 });
271}
272
273fn mock_host_call(
274 capability: &str,
275 operation: &str,
276 params: &BTreeMap<String, VmValue>,
277) -> Option<Result<VmValue, VmError>> {
278 let matched = HOST_MOCKS.with(|mocks| {
279 mocks
280 .borrow()
281 .iter()
282 .rev()
283 .find(|host_mock| {
284 host_mock.capability == capability
285 && host_mock.operation == operation
286 && params_match(host_mock.params.as_ref(), params)
287 })
288 .cloned()
289 })?;
290
291 record_mock_call(capability, operation, params);
292 if let Some(error) = matched.error {
293 return Some(Err(VmError::Thrown(VmValue::String(Rc::from(error)))));
294 }
295 Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
296}
297
298pub trait HostCallBridge {
313 fn dispatch(
314 &self,
315 capability: &str,
316 operation: &str,
317 params: &BTreeMap<String, VmValue>,
318 ) -> Result<Option<VmValue>, VmError>;
319}
320
321thread_local! {
322 static HOST_CALL_BRIDGE: RefCell<Option<Rc<dyn HostCallBridge>>> = const { RefCell::new(None) };
323}
324
325pub fn set_host_call_bridge(bridge: Rc<dyn HostCallBridge>) {
330 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
331}
332
333pub fn clear_host_call_bridge() {
335 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
336}
337
338async fn dispatch_host_operation(
339 capability: &str,
340 operation: &str,
341 params: &BTreeMap<String, VmValue>,
342) -> Result<VmValue, VmError> {
343 if let Some(mocked) = mock_host_call(capability, operation, params) {
344 return mocked;
345 }
346
347 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
348 if let Some(bridge) = bridge {
349 if let Some(value) = bridge.dispatch(capability, operation, params)? {
350 return Ok(value);
351 }
352 }
353
354 match (capability, operation) {
355 ("process", "exec") => {
356 let command = require_param(params, "command")?;
357 let output = tokio::process::Command::new("/bin/sh")
358 .arg("-lc")
359 .arg(&command)
360 .stdin(Stdio::null())
361 .output()
362 .await
363 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
364 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
365 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
366 let mut result = BTreeMap::new();
367 result.insert(
368 "stdout".to_string(),
369 VmValue::String(Rc::from(stdout.clone())),
370 );
371 result.insert(
372 "stderr".to_string(),
373 VmValue::String(Rc::from(stderr.clone())),
374 );
375 result.insert(
376 "combined".to_string(),
377 VmValue::String(Rc::from(format!("{stdout}{stderr}"))),
378 );
379 let status = output.status.code().unwrap_or(-1);
380 result.insert("status".to_string(), VmValue::Int(status as i64));
381 result.insert(
382 "success".to_string(),
383 VmValue::Bool(output.status.success()),
384 );
385 Ok(VmValue::Dict(Rc::new(result)))
386 }
387 ("template", "render") => {
388 let path = require_param(params, "path")?;
389 let bindings = params.get("bindings").and_then(|v| v.as_dict());
390 Ok(VmValue::String(Rc::from(render_template(&path, bindings)?)))
391 }
392 ("interaction", "ask") => {
393 let question = require_param(params, "question")?;
394 use std::io::BufRead;
395 print!("{question}");
396 let _ = std::io::Write::flush(&mut std::io::stdout());
397 let mut input = String::new();
398 if std::io::stdin().lock().read_line(&mut input).is_ok() {
399 Ok(VmValue::String(Rc::from(input.trim_end())))
400 } else {
401 Ok(VmValue::Nil)
402 }
403 }
404 ("runtime", "task") => Ok(VmValue::String(Rc::from(
409 std::env::var("HARN_TASK").unwrap_or_default(),
410 ))),
411 ("runtime", "set_result") => {
412 Ok(VmValue::Nil)
415 }
416 ("workspace", "project_root") => {
417 let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
421 std::env::current_dir()
422 .map(|p| p.display().to_string())
423 .unwrap_or_default()
424 });
425 Ok(VmValue::String(Rc::from(path)))
426 }
427 ("workspace", "cwd") => {
428 let path = std::env::current_dir()
429 .map(|p| p.display().to_string())
430 .unwrap_or_default();
431 Ok(VmValue::String(Rc::from(path)))
432 }
433 _ => Err(VmError::Thrown(VmValue::String(Rc::from(format!(
434 "host_call: unsupported operation {capability}.{operation}"
435 ))))),
436 }
437}
438
439pub(crate) fn register_host_builtins(vm: &mut Vm) {
440 vm.register_builtin("host_mock", |args, _out| {
441 let host_mock = parse_host_mock(args)?;
442 push_host_mock(host_mock);
443 Ok(VmValue::Nil)
444 });
445
446 vm.register_builtin("host_mock_clear", |_args, _out| {
447 reset_host_state();
448 Ok(VmValue::Nil)
449 });
450
451 vm.register_builtin("host_mock_calls", |_args, _out| {
452 let calls = HOST_MOCK_CALLS.with(|calls| {
453 calls
454 .borrow()
455 .iter()
456 .map(mock_call_value)
457 .collect::<Vec<_>>()
458 });
459 Ok(VmValue::List(Rc::new(calls)))
460 });
461
462 vm.register_builtin("host_capabilities", |_args, _out| {
463 Ok(capability_manifest_with_mocks())
464 });
465
466 vm.register_builtin("host_has", |args, _out| {
467 let capability = args.first().map(|a| a.display()).unwrap_or_default();
468 let operation = args.get(1).map(|a| a.display());
469 let manifest = capability_manifest_with_mocks();
470 let has = manifest
471 .as_dict()
472 .and_then(|d| d.get(&capability))
473 .and_then(|v| v.as_dict())
474 .is_some_and(|cap| {
475 if let Some(operation) = operation {
476 cap.get("ops")
477 .and_then(|v| match v {
478 VmValue::List(list) => {
479 Some(list.iter().any(|item| item.display() == operation))
480 }
481 _ => None,
482 })
483 .unwrap_or(false)
484 } else {
485 true
486 }
487 });
488 Ok(VmValue::Bool(has))
489 });
490
491 vm.register_async_builtin("host_call", |args| async move {
492 let name = args.first().map(|a| a.display()).unwrap_or_default();
493 let params = args
494 .get(1)
495 .and_then(|a| a.as_dict())
496 .cloned()
497 .unwrap_or_default();
498 let Some((capability, operation)) = name.split_once('.') else {
499 return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
500 "host_call: unsupported operation name '{name}'"
501 )))));
502 };
503 dispatch_host_operation(capability, operation, ¶ms).await
504 });
505}
506
507#[cfg(test)]
508mod tests {
509 use super::{
510 capability_manifest_with_mocks, mock_host_call, push_host_mock, reset_host_state, HostMock,
511 };
512 use std::collections::BTreeMap;
513 use std::rc::Rc;
514
515 use crate::value::{VmError, VmValue};
516
517 #[test]
518 fn manifest_includes_operation_metadata() {
519 let manifest = capability_manifest_with_mocks();
520 let process = manifest
521 .as_dict()
522 .and_then(|d| d.get("process"))
523 .and_then(|v| v.as_dict())
524 .expect("process capability");
525 assert!(process.get("description").is_some());
526 let operations = process
527 .get("operations")
528 .and_then(|v| v.as_dict())
529 .expect("operations dict");
530 assert!(operations.get("exec").is_some());
531 }
532
533 #[test]
534 fn mocked_capabilities_appear_in_manifest() {
535 reset_host_state();
536 push_host_mock(HostMock {
537 capability: "project".to_string(),
538 operation: "metadata_get".to_string(),
539 params: None,
540 result: Some(VmValue::Dict(Rc::new(BTreeMap::new()))),
541 error: None,
542 });
543 let manifest = capability_manifest_with_mocks();
544 let project = manifest
545 .as_dict()
546 .and_then(|d| d.get("project"))
547 .and_then(|v| v.as_dict())
548 .expect("project capability");
549 let operations = project
550 .get("operations")
551 .and_then(|v| v.as_dict())
552 .expect("operations dict");
553 assert!(operations.get("metadata_get").is_some());
554 reset_host_state();
555 }
556
557 #[test]
558 fn mock_host_call_matches_partial_params_and_overrides_order() {
559 reset_host_state();
560 let mut exact_params = BTreeMap::new();
561 exact_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
562 push_host_mock(HostMock {
563 capability: "project".to_string(),
564 operation: "metadata_get".to_string(),
565 params: None,
566 result: Some(VmValue::String(Rc::from("fallback"))),
567 error: None,
568 });
569 push_host_mock(HostMock {
570 capability: "project".to_string(),
571 operation: "metadata_get".to_string(),
572 params: Some(exact_params),
573 result: Some(VmValue::String(Rc::from("facts"))),
574 error: None,
575 });
576
577 let mut call_params = BTreeMap::new();
578 call_params.insert("dir".to_string(), VmValue::String(Rc::from("pkg")));
579 call_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
580 let exact = mock_host_call("project", "metadata_get", &call_params)
581 .expect("expected exact mock")
582 .expect("exact mock should succeed");
583 assert_eq!(exact.display(), "facts");
584
585 call_params.insert(
586 "namespace".to_string(),
587 VmValue::String(Rc::from("classification")),
588 );
589 let fallback = mock_host_call("project", "metadata_get", &call_params)
590 .expect("expected fallback mock")
591 .expect("fallback mock should succeed");
592 assert_eq!(fallback.display(), "fallback");
593 reset_host_state();
594 }
595
596 #[test]
597 fn mock_host_call_can_throw_errors() {
598 reset_host_state();
599 push_host_mock(HostMock {
600 capability: "project".to_string(),
601 operation: "metadata_get".to_string(),
602 params: None,
603 result: None,
604 error: Some("boom".to_string()),
605 });
606 let params = BTreeMap::new();
607 let result =
608 mock_host_call("project", "metadata_get", ¶ms).expect("expected mock result");
609 match result {
610 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
611 other => panic!("unexpected result: {other:?}"),
612 }
613 reset_host_state();
614 }
615}