Skip to main content

harn_vm/stdlib/
host.rs

1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::process::Stdio;
4use std::rc::Rc;
5
6use serde_json::Value as JsonValue;
7
8use crate::value::{values_equal, VmError, VmValue};
9use crate::vm::clone_async_builtin_child_vm;
10use crate::vm::Vm;
11
12#[derive(Clone)]
13struct HostMock {
14    capability: String,
15    operation: String,
16    params: Option<BTreeMap<String, VmValue>>,
17    result: Option<VmValue>,
18    error: Option<String>,
19}
20
21#[derive(Clone)]
22struct HostMockCall {
23    capability: String,
24    operation: String,
25    params: BTreeMap<String, VmValue>,
26}
27
28thread_local! {
29    static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
30    static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
31    static HOST_MOCK_SCOPES: RefCell<Vec<(Vec<HostMock>, Vec<HostMockCall>)>> =
32        const { RefCell::new(Vec::new()) };
33}
34
35pub(crate) fn reset_host_state() {
36    HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
37    HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
38    HOST_MOCK_SCOPES.with(|scopes| scopes.borrow_mut().clear());
39}
40
41/// Push the current host-mock state onto an internal stack and start a
42/// fresh empty scope. Paired with `pop_host_mock_scope`. Used by the
43/// `with_host_mocks` helper in `std/testing` to give tests automatic
44/// cleanup, including when the body throws.
45fn push_host_mock_scope() {
46    let mocks = HOST_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
47    let calls = HOST_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
48    HOST_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls)));
49}
50
51/// Restore the most recently pushed host-mock state, replacing any
52/// mocks or recorded calls accumulated inside the scope. Returns
53/// `false` if there is no saved scope to pop, so callers can surface a
54/// clear "imbalanced scope" error rather than silently no-op'ing.
55fn pop_host_mock_scope() -> bool {
56    let entry = HOST_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
57    match entry {
58        Some((mocks, calls)) => {
59            HOST_MOCKS.with(|v| *v.borrow_mut() = mocks);
60            HOST_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
61            true
62        }
63        None => false,
64    }
65}
66
67fn capability_manifest_map() -> BTreeMap<String, VmValue> {
68    let mut root = BTreeMap::new();
69    root.insert(
70        "process".to_string(),
71        capability(
72            "Process execution.",
73            &[op("exec", "Execute a shell command.")],
74        ),
75    );
76    root.insert(
77        "template".to_string(),
78        capability(
79            "Template rendering.",
80            &[op("render", "Render a template file.")],
81        ),
82    );
83    root.insert(
84        "interaction".to_string(),
85        capability(
86            "User interaction.",
87            &[op("ask", "Ask the user a question.")],
88        ),
89    );
90    root
91}
92
93fn mocked_operation_entry() -> VmValue {
94    op(
95        "mocked",
96        "Mocked host operation registered at runtime for tests.",
97    )
98    .1
99}
100
101fn ensure_mocked_capability(
102    root: &mut BTreeMap<String, VmValue>,
103    capability_name: &str,
104    operation_name: &str,
105) {
106    let Some(existing) = root.get(capability_name).cloned() else {
107        root.insert(
108            capability_name.to_string(),
109            capability(
110                "Mocked host capability registered at runtime for tests.",
111                &[(operation_name.to_string(), mocked_operation_entry())],
112            ),
113        );
114        return;
115    };
116
117    let Some(existing_dict) = existing.as_dict() else {
118        return;
119    };
120    let mut entry = (*existing_dict).clone();
121    let mut ops = entry
122        .get("ops")
123        .and_then(|value| match value {
124            VmValue::List(list) => Some((**list).clone()),
125            _ => None,
126        })
127        .unwrap_or_default();
128    if !ops.iter().any(|value| value.display() == operation_name) {
129        ops.push(VmValue::String(Rc::from(operation_name.to_string())));
130    }
131
132    let mut operations = entry
133        .get("operations")
134        .and_then(|value| value.as_dict())
135        .map(|dict| (*dict).clone())
136        .unwrap_or_default();
137    operations
138        .entry(operation_name.to_string())
139        .or_insert_with(mocked_operation_entry);
140
141    entry.insert("ops".to_string(), VmValue::List(Rc::new(ops)));
142    entry.insert("operations".to_string(), VmValue::Dict(Rc::new(operations)));
143    root.insert(capability_name.to_string(), VmValue::Dict(Rc::new(entry)));
144}
145
146fn capability_manifest_with_mocks() -> VmValue {
147    let mut root = capability_manifest_map();
148    HOST_MOCKS.with(|mocks| {
149        for host_mock in mocks.borrow().iter() {
150            ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
151        }
152    });
153    VmValue::Dict(Rc::new(root))
154}
155
156fn op(name: &str, description: &str) -> (String, VmValue) {
157    let mut entry = BTreeMap::new();
158    entry.insert(
159        "description".to_string(),
160        VmValue::String(Rc::from(description)),
161    );
162    (name.to_string(), VmValue::Dict(Rc::new(entry)))
163}
164
165fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
166    let mut entry = BTreeMap::new();
167    entry.insert(
168        "description".to_string(),
169        VmValue::String(Rc::from(description)),
170    );
171    entry.insert(
172        "ops".to_string(),
173        VmValue::List(Rc::new(
174            ops.iter()
175                .map(|(name, _)| VmValue::String(Rc::from(name.as_str())))
176                .collect(),
177        )),
178    );
179    let mut op_dict = BTreeMap::new();
180    for (name, op) in ops {
181        op_dict.insert(name.clone(), op.clone());
182    }
183    entry.insert("operations".to_string(), VmValue::Dict(Rc::new(op_dict)));
184    VmValue::Dict(Rc::new(entry))
185}
186
187fn require_param(params: &BTreeMap<String, VmValue>, key: &str) -> Result<String, VmError> {
188    params
189        .get(key)
190        .map(|v| v.display())
191        .filter(|v| !v.is_empty())
192        .ok_or_else(|| {
193            VmError::Thrown(VmValue::String(Rc::from(format!(
194                "host_call: missing required parameter '{key}'"
195            ))))
196        })
197}
198
199fn render_template(
200    path: &str,
201    bindings: Option<&BTreeMap<String, VmValue>>,
202) -> Result<String, VmError> {
203    let resolved = crate::stdlib::asset_paths::resolve_or_source_relative(path, None)
204        .map_err(|msg| VmError::Thrown(VmValue::String(Rc::from(msg))))?;
205    let template = std::fs::read_to_string(&resolved).map_err(|e| {
206        VmError::Thrown(VmValue::String(Rc::from(format!(
207            "host_call template.render: failed to read template {}: {e}",
208            resolved.display()
209        ))))
210    })?;
211    let base = resolved.parent();
212    crate::stdlib::template::render_template_result(&template, bindings, base, Some(&resolved))
213        .map_err(VmError::from)
214}
215
216fn params_match(
217    expected: Option<&BTreeMap<String, VmValue>>,
218    actual: &BTreeMap<String, VmValue>,
219) -> bool {
220    let Some(expected) = expected else {
221        return true;
222    };
223    expected.iter().all(|(key, value)| {
224        actual
225            .get(key)
226            .is_some_and(|candidate| values_equal(candidate, value))
227    })
228}
229
230fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
231    let capability = args
232        .first()
233        .map(|value| value.display())
234        .unwrap_or_default();
235    let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
236    if capability.is_empty() || operation.is_empty() {
237        return Err(VmError::Thrown(VmValue::String(Rc::from(
238            "host_mock: capability and operation are required",
239        ))));
240    }
241
242    let mut params = args
243        .get(3)
244        .and_then(|value| value.as_dict())
245        .map(|dict| (*dict).clone());
246    let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
247    let mut error = None;
248
249    if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
250        if config.contains_key("result")
251            || config.contains_key("params")
252            || config.contains_key("error")
253        {
254            params = config
255                .get("params")
256                .and_then(|value| value.as_dict())
257                .map(|dict| (*dict).clone());
258            result = config.get("result").cloned();
259            error = config
260                .get("error")
261                .map(|value| value.display())
262                .filter(|value| !value.is_empty());
263        }
264    }
265
266    Ok(HostMock {
267        capability,
268        operation,
269        params,
270        result,
271        error,
272    })
273}
274
275fn push_host_mock(host_mock: HostMock) {
276    HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
277}
278
279fn mock_call_value(call: &HostMockCall) -> VmValue {
280    let mut item = BTreeMap::new();
281    item.insert(
282        "capability".to_string(),
283        VmValue::String(Rc::from(call.capability.clone())),
284    );
285    item.insert(
286        "operation".to_string(),
287        VmValue::String(Rc::from(call.operation.clone())),
288    );
289    item.insert(
290        "params".to_string(),
291        VmValue::Dict(Rc::new(call.params.clone())),
292    );
293    VmValue::Dict(Rc::new(item))
294}
295
296fn record_mock_call(capability: &str, operation: &str, params: &BTreeMap<String, VmValue>) {
297    HOST_MOCK_CALLS.with(|calls| {
298        calls.borrow_mut().push(HostMockCall {
299            capability: capability.to_string(),
300            operation: operation.to_string(),
301            params: params.clone(),
302        });
303    });
304}
305
306pub(crate) fn dispatch_mock_host_call(
307    capability: &str,
308    operation: &str,
309    params: &BTreeMap<String, VmValue>,
310) -> Option<Result<VmValue, VmError>> {
311    let matched = HOST_MOCKS.with(|mocks| {
312        mocks
313            .borrow()
314            .iter()
315            .rev()
316            .find(|host_mock| {
317                host_mock.capability == capability
318                    && host_mock.operation == operation
319                    && params_match(host_mock.params.as_ref(), params)
320            })
321            .cloned()
322    })?;
323
324    record_mock_call(capability, operation, params);
325    if let Some(error) = matched.error {
326        return Some(Err(VmError::Thrown(VmValue::String(Rc::from(error)))));
327    }
328    Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
329}
330
331/// Embedder-supplied bridge for `host_call` ops.
332///
333/// Embedders (debug adapters, CLIs, IDE hosts) implement this trait to
334/// satisfy capability/operation pairs that harn-vm itself doesn't know how
335/// to handle. Returning `Ok(None)` means "I don't handle this op — fall
336/// through to the built-in fallbacks (env-derived defaults, then the
337/// `unsupported operation` error)". `Ok(Some(value))` is the result;
338/// `Err(VmError::Thrown(_))` surfaces as a Harn exception.
339///
340/// The trait is intentionally synchronous. Bridges that need async I/O
341/// (e.g. DAP reverse requests) should drive their own runtime or use a
342/// blocking channel — see `harn-dap`'s `DapHostBridge` for the canonical
343/// pattern. Sync keeps the boundary simple and avoids forcing the entire
344/// dispatch path into an opaque future.
345pub trait HostCallBridge {
346    fn dispatch(
347        &self,
348        capability: &str,
349        operation: &str,
350        params: &BTreeMap<String, VmValue>,
351    ) -> Result<Option<VmValue>, VmError>;
352
353    fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
354        Ok(None)
355    }
356
357    fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
358        Ok(None)
359    }
360}
361
362thread_local! {
363    static HOST_CALL_BRIDGE: RefCell<Option<Rc<dyn HostCallBridge>>> = const { RefCell::new(None) };
364}
365
366/// Install a bridge for the current thread. The bridge is consulted on
367/// every `host_call` *after* mock matching but *before* the built-in
368/// match arms, so embedders can override anything they like (and equally
369/// punt on anything they don't, by returning `Ok(None)`).
370pub fn set_host_call_bridge(bridge: Rc<dyn HostCallBridge>) {
371    HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
372}
373
374/// Remove the current thread's bridge. Idempotent.
375pub fn clear_host_call_bridge() {
376    HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
377}
378
379fn empty_tool_list_value() -> VmValue {
380    VmValue::List(Rc::new(Vec::new()))
381}
382
383fn current_vm_host_bridge() -> Option<Rc<crate::bridge::HostBridge>> {
384    clone_async_builtin_child_vm().and_then(|vm| vm.bridge.clone())
385}
386
387async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
388    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
389    if let Some(bridge) = bridge {
390        if let Some(value) = bridge.list_tools()? {
391            return Ok(value);
392        }
393    }
394
395    let Some(bridge) = current_vm_host_bridge() else {
396        return Ok(empty_tool_list_value());
397    };
398    let tools = bridge.list_host_tools().await?;
399    Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
400        tools.into_iter().collect(),
401    )))
402}
403
404async fn dispatch_host_tool_call(name: &str, args: &VmValue) -> Result<VmValue, VmError> {
405    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
406    if let Some(bridge) = bridge {
407        if let Some(value) = bridge.call_tool(name, args)? {
408            return Ok(value);
409        }
410    }
411
412    let Some(bridge) = current_vm_host_bridge() else {
413        return Err(VmError::Thrown(VmValue::String(Rc::from(
414            "host_tool_call: no host bridge is attached",
415        ))));
416    };
417
418    let result = bridge
419        .call(
420            "builtin_call",
421            serde_json::json!({
422                "name": name,
423                "args": [crate::llm::vm_value_to_json(args)],
424            }),
425        )
426        .await?;
427    Ok(crate::bridge::json_result_to_vm_value(&result))
428}
429
430async fn dispatch_host_operation(
431    capability: &str,
432    operation: &str,
433    params: &BTreeMap<String, VmValue>,
434) -> Result<VmValue, VmError> {
435    if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
436        return mocked;
437    }
438
439    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
440    if let Some(bridge) = bridge {
441        if let Some(value) = bridge.dispatch(capability, operation, params)? {
442            return Ok(value);
443        }
444    }
445
446    match (capability, operation) {
447        ("process", "exec") => {
448            let command = require_param(params, "command")?;
449            let mut cmd = if cfg!(windows) {
450                let mut c = tokio::process::Command::new("cmd");
451                c.arg("/C").arg(&command);
452                c
453            } else {
454                let mut c = tokio::process::Command::new("/bin/sh");
455                c.arg("-lc").arg(&command);
456                c
457            };
458            let output = cmd
459                .stdin(Stdio::null())
460                .output()
461                .await
462                .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
463            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
464            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
465            let mut result = BTreeMap::new();
466            result.insert(
467                "stdout".to_string(),
468                VmValue::String(Rc::from(stdout.clone())),
469            );
470            result.insert(
471                "stderr".to_string(),
472                VmValue::String(Rc::from(stderr.clone())),
473            );
474            result.insert(
475                "combined".to_string(),
476                VmValue::String(Rc::from(format!("{stdout}{stderr}"))),
477            );
478            let status = output.status.code().unwrap_or(-1);
479            result.insert("status".to_string(), VmValue::Int(status as i64));
480            result.insert(
481                "success".to_string(),
482                VmValue::Bool(output.status.success()),
483            );
484            Ok(VmValue::Dict(Rc::new(result)))
485        }
486        ("template", "render") => {
487            let path = require_param(params, "path")?;
488            let bindings = params.get("bindings").and_then(|v| v.as_dict());
489            Ok(VmValue::String(Rc::from(render_template(&path, bindings)?)))
490        }
491        ("interaction", "ask") => {
492            let question = require_param(params, "question")?;
493            use std::io::BufRead;
494            print!("{question}");
495            let _ = std::io::Write::flush(&mut std::io::stdout());
496            let mut input = String::new();
497            if std::io::stdin().lock().read_line(&mut input).is_ok() {
498                Ok(VmValue::String(Rc::from(input.trim_end())))
499            } else {
500                Ok(VmValue::Nil)
501            }
502        }
503        // Standalone-run fallbacks for capabilities normally supplied by
504        // an embedder's JSON-RPC bridge. `runtime.task` lets a debugger or
505        // CLI invocation read the pipeline input from `HARN_TASK` without
506        // the host explicitly wiring a callback for every op.
507        ("runtime", "task") => Ok(VmValue::String(Rc::from(
508            std::env::var("HARN_TASK").unwrap_or_default(),
509        ))),
510        ("runtime", "set_result") => {
511            // No-op when no host is attached; swallow silently so standalone
512            // scripts can still call `set_result` without crashing.
513            Ok(VmValue::Nil)
514        }
515        ("workspace", "project_root") => {
516            // Standalone fallback: prefer HARN_PROJECT_ROOT, then the
517            // current working directory. Pipelines call this very early so
518            // crashing here would block any debug-launched script.
519            let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
520                std::env::current_dir()
521                    .map(|p| p.display().to_string())
522                    .unwrap_or_default()
523            });
524            Ok(VmValue::String(Rc::from(path)))
525        }
526        ("workspace", "cwd") => {
527            let path = std::env::current_dir()
528                .map(|p| p.display().to_string())
529                .unwrap_or_default();
530            Ok(VmValue::String(Rc::from(path)))
531        }
532        _ => Err(VmError::Thrown(VmValue::String(Rc::from(format!(
533            "host_call: unsupported operation {capability}.{operation}"
534        ))))),
535    }
536}
537
538pub(crate) fn register_host_builtins(vm: &mut Vm) {
539    vm.register_builtin("host_mock", |args, _out| {
540        let host_mock = parse_host_mock(args)?;
541        push_host_mock(host_mock);
542        Ok(VmValue::Nil)
543    });
544
545    vm.register_builtin("host_mock_clear", |_args, _out| {
546        reset_host_state();
547        Ok(VmValue::Nil)
548    });
549
550    vm.register_builtin("host_mock_calls", |_args, _out| {
551        let calls = HOST_MOCK_CALLS.with(|calls| {
552            calls
553                .borrow()
554                .iter()
555                .map(mock_call_value)
556                .collect::<Vec<_>>()
557        });
558        Ok(VmValue::List(Rc::new(calls)))
559    });
560
561    vm.register_builtin("host_mock_push_scope", |_args, _out| {
562        push_host_mock_scope();
563        Ok(VmValue::Nil)
564    });
565
566    vm.register_builtin("host_mock_pop_scope", |_args, _out| {
567        if !pop_host_mock_scope() {
568            return Err(VmError::Thrown(VmValue::String(Rc::from(
569                "host_mock_pop_scope: no scope to pop",
570            ))));
571        }
572        Ok(VmValue::Nil)
573    });
574
575    vm.register_builtin("host_capabilities", |_args, _out| {
576        Ok(capability_manifest_with_mocks())
577    });
578
579    vm.register_builtin("host_has", |args, _out| {
580        let capability = args.first().map(|a| a.display()).unwrap_or_default();
581        let operation = args.get(1).map(|a| a.display());
582        let manifest = capability_manifest_with_mocks();
583        let has = manifest
584            .as_dict()
585            .and_then(|d| d.get(&capability))
586            .and_then(|v| v.as_dict())
587            .is_some_and(|cap| {
588                if let Some(operation) = operation {
589                    cap.get("ops")
590                        .and_then(|v| match v {
591                            VmValue::List(list) => {
592                                Some(list.iter().any(|item| item.display() == operation))
593                            }
594                            _ => None,
595                        })
596                        .unwrap_or(false)
597                } else {
598                    true
599                }
600            });
601        Ok(VmValue::Bool(has))
602    });
603
604    vm.register_async_builtin("host_call", |args| async move {
605        let name = args.first().map(|a| a.display()).unwrap_or_default();
606        let params = args
607            .get(1)
608            .and_then(|a| a.as_dict())
609            .cloned()
610            .unwrap_or_default();
611        let Some((capability, operation)) = name.split_once('.') else {
612            return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
613                "host_call: unsupported operation name '{name}'"
614            )))));
615        };
616        dispatch_host_operation(capability, operation, &params).await
617    });
618
619    vm.register_async_builtin("host_tool_list", |_args| async move {
620        dispatch_host_tool_list().await
621    });
622
623    vm.register_async_builtin("host_tool_call", |args| async move {
624        let name = args.first().map(|a| a.display()).unwrap_or_default();
625        if name.is_empty() {
626            return Err(VmError::Thrown(VmValue::String(Rc::from(
627                "host_tool_call: tool name is required",
628            ))));
629        }
630        let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
631        dispatch_host_tool_call(&name, &call_args).await
632    });
633}
634
635#[cfg(test)]
636mod tests {
637    use super::{
638        capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_tool_call,
639        dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock, reset_host_state,
640        set_host_call_bridge, HostCallBridge, HostMock,
641    };
642    use std::collections::BTreeMap;
643    use std::rc::Rc;
644
645    use crate::value::{VmError, VmValue};
646
647    #[test]
648    fn manifest_includes_operation_metadata() {
649        let manifest = capability_manifest_with_mocks();
650        let process = manifest
651            .as_dict()
652            .and_then(|d| d.get("process"))
653            .and_then(|v| v.as_dict())
654            .expect("process capability");
655        assert!(process.get("description").is_some());
656        let operations = process
657            .get("operations")
658            .and_then(|v| v.as_dict())
659            .expect("operations dict");
660        assert!(operations.get("exec").is_some());
661    }
662
663    #[test]
664    fn mocked_capabilities_appear_in_manifest() {
665        reset_host_state();
666        push_host_mock(HostMock {
667            capability: "project".to_string(),
668            operation: "metadata_get".to_string(),
669            params: None,
670            result: Some(VmValue::Dict(Rc::new(BTreeMap::new()))),
671            error: None,
672        });
673        let manifest = capability_manifest_with_mocks();
674        let project = manifest
675            .as_dict()
676            .and_then(|d| d.get("project"))
677            .and_then(|v| v.as_dict())
678            .expect("project capability");
679        let operations = project
680            .get("operations")
681            .and_then(|v| v.as_dict())
682            .expect("operations dict");
683        assert!(operations.get("metadata_get").is_some());
684        reset_host_state();
685    }
686
687    #[test]
688    fn mock_host_call_matches_partial_params_and_overrides_order() {
689        reset_host_state();
690        let mut exact_params = BTreeMap::new();
691        exact_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
692        push_host_mock(HostMock {
693            capability: "project".to_string(),
694            operation: "metadata_get".to_string(),
695            params: None,
696            result: Some(VmValue::String(Rc::from("fallback"))),
697            error: None,
698        });
699        push_host_mock(HostMock {
700            capability: "project".to_string(),
701            operation: "metadata_get".to_string(),
702            params: Some(exact_params),
703            result: Some(VmValue::String(Rc::from("facts"))),
704            error: None,
705        });
706
707        let mut call_params = BTreeMap::new();
708        call_params.insert("dir".to_string(), VmValue::String(Rc::from("pkg")));
709        call_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
710        let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
711            .expect("expected exact mock")
712            .expect("exact mock should succeed");
713        assert_eq!(exact.display(), "facts");
714
715        call_params.insert(
716            "namespace".to_string(),
717            VmValue::String(Rc::from("classification")),
718        );
719        let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
720            .expect("expected fallback mock")
721            .expect("fallback mock should succeed");
722        assert_eq!(fallback.display(), "fallback");
723        reset_host_state();
724    }
725
726    #[test]
727    fn mock_host_call_can_throw_errors() {
728        reset_host_state();
729        push_host_mock(HostMock {
730            capability: "project".to_string(),
731            operation: "metadata_get".to_string(),
732            params: None,
733            result: None,
734            error: Some("boom".to_string()),
735        });
736        let params = BTreeMap::new();
737        let result = dispatch_mock_host_call("project", "metadata_get", &params)
738            .expect("expected mock result");
739        match result {
740            Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
741            other => panic!("unexpected result: {other:?}"),
742        }
743        reset_host_state();
744    }
745
746    #[derive(Default)]
747    struct TestHostToolBridge;
748
749    impl HostCallBridge for TestHostToolBridge {
750        fn dispatch(
751            &self,
752            _capability: &str,
753            _operation: &str,
754            _params: &BTreeMap<String, VmValue>,
755        ) -> Result<Option<VmValue>, VmError> {
756            Ok(None)
757        }
758
759        fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
760            let tool = VmValue::Dict(Rc::new(BTreeMap::from([
761                (
762                    "name".to_string(),
763                    VmValue::String(Rc::from("Read".to_string())),
764                ),
765                (
766                    "description".to_string(),
767                    VmValue::String(Rc::from("Read a file from the host".to_string())),
768                ),
769                (
770                    "schema".to_string(),
771                    VmValue::Dict(Rc::new(BTreeMap::from([(
772                        "type".to_string(),
773                        VmValue::String(Rc::from("object".to_string())),
774                    )]))),
775                ),
776                ("deprecated".to_string(), VmValue::Bool(false)),
777            ])));
778            Ok(Some(VmValue::List(Rc::new(vec![tool]))))
779        }
780
781        fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
782            if name != "Read" {
783                return Ok(None);
784            }
785            let path = args
786                .as_dict()
787                .and_then(|dict| dict.get("path"))
788                .map(|value| value.display())
789                .unwrap_or_default();
790            Ok(Some(VmValue::String(Rc::from(format!("read:{path}")))))
791        }
792    }
793
794    fn run_host_async_test<F, Fut>(test: F)
795    where
796        F: FnOnce() -> Fut,
797        Fut: std::future::Future<Output = ()>,
798    {
799        let rt = tokio::runtime::Builder::new_current_thread()
800            .enable_all()
801            .build()
802            .expect("runtime");
803        rt.block_on(async {
804            let local = tokio::task::LocalSet::new();
805            local.run_until(test()).await;
806        });
807    }
808
809    #[test]
810    fn host_tool_list_uses_installed_host_call_bridge() {
811        run_host_async_test(|| async {
812            reset_host_state();
813            set_host_call_bridge(Rc::new(TestHostToolBridge));
814            let tools = dispatch_host_tool_list().await.expect("tool list");
815            clear_host_call_bridge();
816
817            let VmValue::List(items) = tools else {
818                panic!("expected tool list");
819            };
820            assert_eq!(items.len(), 1);
821            let tool = items[0].as_dict().expect("tool dict");
822            assert_eq!(tool.get("name").unwrap().display(), "Read");
823            assert_eq!(tool.get("deprecated").unwrap().display(), "false");
824        });
825    }
826
827    #[test]
828    fn host_tool_call_uses_installed_host_call_bridge() {
829        run_host_async_test(|| async {
830            set_host_call_bridge(Rc::new(TestHostToolBridge));
831            let args = VmValue::Dict(Rc::new(BTreeMap::from([(
832                "path".to_string(),
833                VmValue::String(Rc::from("README.md".to_string())),
834            )])));
835            let value = dispatch_host_tool_call("Read", &args)
836                .await
837                .expect("tool call");
838            clear_host_call_bridge();
839            assert_eq!(value.display(), "read:README.md");
840        });
841    }
842
843    #[test]
844    fn host_tool_list_is_empty_without_bridge() {
845        run_host_async_test(|| async {
846            clear_host_call_bridge();
847            let tools = dispatch_host_tool_list().await.expect("tool list");
848            let VmValue::List(items) = tools else {
849                panic!("expected tool list");
850            };
851            assert!(items.is_empty());
852        });
853    }
854}