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 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
298/// Embedder-supplied bridge for `host_call` ops.
299///
300/// Embedders (debug adapters, CLIs, IDE hosts) implement this trait to
301/// satisfy capability/operation pairs that harn-vm itself doesn't know how
302/// to handle. Returning `Ok(None)` means "I don't handle this op — fall
303/// through to the built-in fallbacks (env-derived defaults, then the
304/// `unsupported operation` error)". `Ok(Some(value))` is the result;
305/// `Err(VmError::Thrown(_))` surfaces as a Harn exception.
306///
307/// The trait is intentionally synchronous. Bridges that need async I/O
308/// (e.g. DAP reverse requests) should drive their own runtime or use a
309/// blocking channel — see `harn-dap`'s `DapHostBridge` for the canonical
310/// pattern. Sync keeps the boundary simple and avoids forcing the entire
311/// dispatch path into an opaque future.
312pub 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
325/// Install a bridge for the current thread. The bridge is consulted on
326/// every `host_call` *after* mock matching but *before* the built-in
327/// match arms, so embedders can override anything they like (and equally
328/// punt on anything they don't, by returning `Ok(None)`).
329pub fn set_host_call_bridge(bridge: Rc<dyn HostCallBridge>) {
330    HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
331}
332
333/// Remove the current thread's bridge. Idempotent.
334pub 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        // Standalone-run fallbacks for capabilities normally supplied by
405        // an embedder's JSON-RPC bridge. `runtime.task` lets a debugger or
406        // CLI invocation read the pipeline input from `HARN_TASK` without
407        // the host explicitly wiring a callback for every op.
408        ("runtime", "task") => Ok(VmValue::String(Rc::from(
409            std::env::var("HARN_TASK").unwrap_or_default(),
410        ))),
411        ("runtime", "set_result") => {
412            // No-op when no host is attached; swallow silently so standalone
413            // scripts can still call `set_result` without crashing.
414            Ok(VmValue::Nil)
415        }
416        ("workspace", "project_root") => {
417            // Standalone fallback: prefer HARN_PROJECT_ROOT, then the
418            // current working directory. Pipelines call this very early so
419            // crashing here would block any debug-launched script.
420            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, &params).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", &params).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}