Skip to main content

moonlit_pdk/
context.rs

1//! [`Context`], and the [`Host`] bridge behind it.
2//!
3//! `Host` abstracts the generated WIT imports, so the same middleware runs
4//! against the real host under wasm or a [`MockHost`](crate::testing::MockHost)
5//! in a native test.
6
7use std::any::Any;
8
9/// Log severity surfaced to the CLI renderer.
10#[derive(Clone, Copy, PartialEq, Eq, Debug)]
11pub enum LogLevel {
12  Debug,
13  Info,
14  Warn,
15  Error,
16}
17
18/// The capabilities `Context` needs from the environment.
19pub trait Host {
20  fn log(&self, level: LogLevel, message: &str);
21  fn get_config(&self, path: &str) -> Option<String>;
22  fn report_progress(&self, message: &str);
23  /// Run a subprocess to completion, capturing output.
24  fn process_run(&self, cmd: &crate::process::ProcessCommand) -> Result<crate::process::ProcessOutput, String>;
25  /// Spawn a subprocess for streaming.
26  fn process_spawn(&self, cmd: &crate::process::ProcessCommand) -> Result<Box<dyn crate::process::ChildHandle>, String>;
27  /// Perform a blocking HTTP round-trip.
28  fn http_send(&self, req: &crate::http::HttpRequestData) -> Result<crate::http::HttpResponseData, String>;
29  /// Read an environment variable (routes to `wasi:cli/environment`, already
30  /// permission-filtered by the engine). `None` if unset or filtered out.
31  fn env_var(&self, name: &str) -> Option<String>;
32  /// All visible environment variables.
33  fn env_vars(&self) -> Vec<(String, String)>;
34  /// Cryptographically-strong random bytes (routes to `wasi:random` on the
35  /// real host; a deterministic mock in tests). Returns at least `n` bytes.
36  fn random_bytes(&self, n: usize) -> Vec<u8>;
37  /// Monotonic clock reading in nanoseconds (routes to
38  /// `wasi:clocks/monotonic-clock` on the real host; a controllable mock in
39  /// tests). Never decreases.
40  fn monotonic_nanos(&self) -> u64;
41  /// Sleep for `nanos` nanoseconds (routes to `wasi:clocks/monotonic-clock`
42  /// `subscribe-duration().block()` on the real host; recorded, no real wait,
43  /// in tests).
44  fn sleep_nanos(&self, nanos: u64);
45}
46
47/// Execution context handed to every middleware.
48pub struct Context<'a> {
49  host: &'a dyn Host,
50  working_dir: String,
51  step_name: String,
52  state: Option<&'a dyn Any>,
53  plugin_config: Option<&'a dyn Any>,
54}
55
56impl<'a> Context<'a> {
57  #[must_use]
58  pub fn new(host: &'a dyn Host, working_dir: String, step_name: String) -> Self {
59    Self {
60      host,
61      working_dir,
62      step_name,
63      state: None,
64      plugin_config: None,
65    }
66  }
67  #[must_use]
68  pub fn with_state(mut self, state: &'a dyn Any) -> Self {
69    self.state = Some(state);
70    self
71  }
72  #[must_use]
73  pub fn with_plugin_config(mut self, cfg: &'a dyn Any) -> Self {
74    self.plugin_config = Some(cfg);
75    self
76  }
77
78  pub fn log_debug(&self, msg: &str) {
79    self.host.log(LogLevel::Debug, msg);
80  }
81  pub fn log_info(&self, msg: &str) {
82    self.host.log(LogLevel::Info, msg);
83  }
84  pub fn log_warn(&self, msg: &str) {
85    self.host.log(LogLevel::Warn, msg);
86  }
87  pub fn log_error(&self, msg: &str) {
88    self.host.log(LogLevel::Error, msg);
89  }
90  pub fn progress(&self, msg: &str) {
91    self.host.report_progress(msg);
92  }
93
94  #[must_use]
95  pub fn working_dir(&self) -> &str {
96    &self.working_dir
97  }
98  #[must_use]
99  pub fn step_name(&self) -> &str {
100    &self.step_name
101  }
102
103  /// Raw accumulated config at `path` (`:`-separated), parsed from json-value.
104  #[must_use]
105  pub fn get_config(&self, path: &str) -> Option<serde_json::Value> {
106    let raw = self.host.get_config(path)?;
107    serde_json::from_str(&raw).ok()
108  }
109  /// Config at `path`, coerced (ยง5.4) into `T`. `None` if absent or on error.
110  #[must_use]
111  pub fn get_config_as<T: serde::de::DeserializeOwned>(&self, path: &str) -> Option<T> {
112    let raw = self.host.get_config(path)?;
113    crate::config::from_json_value(&raw).ok()
114  }
115
116  /// Environment access.
117  #[must_use]
118  pub fn env(&self) -> crate::env::Env<'a> {
119    crate::env::Env::new(self.host)
120  }
121
122  /// Monotonic clock access.
123  #[must_use]
124  pub fn clock(&self) -> crate::clock::Clock<'a> {
125    crate::clock::Clock::new(self.host)
126  }
127
128  /// Randomness access.
129  #[must_use]
130  pub fn random(&self) -> crate::random::Random<'a> {
131    crate::random::Random::new(self.host)
132  }
133
134  /// Subprocess builder for `program`.
135  pub fn command(&self, program: impl Into<String>) -> crate::process::Command<'a> {
136    crate::process::Command::new(self.host, program)
137  }
138
139  /// Blocking HTTP client.
140  #[must_use]
141  pub fn http(&self) -> crate::http::Http<'a> {
142    crate::http::Http::new(self.host)
143  }
144
145  /// The plugin's shared state. Panics if the plugin declared no `state:`.
146  #[must_use]
147  pub fn state<T: 'static>(&self) -> &T {
148    self
149      .state
150      .expect("this plugin declared no `state:` in moonlit_plugin!")
151      .downcast_ref::<T>()
152      .expect("state type mismatch")
153  }
154  /// The typed plugin-level config. Panics if the plugin declared no `config:`.
155  #[must_use]
156  pub fn plugin_config<T: 'static>(&self) -> &T {
157    self
158      .plugin_config
159      .expect("this plugin declared no `config:` in moonlit_plugin!")
160      .downcast_ref::<T>()
161      .expect("plugin config type mismatch")
162  }
163}
164
165#[cfg(test)]
166mod tests {
167  use super::*;
168  use crate::testing::MockHost;
169
170  #[test]
171  fn log_and_progress_route_to_host() {
172    let host = MockHost::new();
173    let ctx = Context::new(&host, "/w".into(), "compile".into());
174    ctx.log_info("hi");
175    ctx.log_error("bad");
176    ctx.progress("50%");
177    assert_eq!(ctx.working_dir(), "/w");
178    assert_eq!(ctx.step_name(), "compile");
179    assert_eq!(
180      host.logs(),
181      vec![(LogLevel::Info, "hi".to_string()), (LogLevel::Error, "bad".to_string()),]
182    );
183    assert_eq!(host.progress(), vec!["50%".to_string()]);
184  }
185
186  #[test]
187  fn debug_and_warn_logs_route_to_host() {
188    let host = MockHost::new();
189    let ctx = Context::new(&host, "/w".into(), "s".into());
190    ctx.log_debug("d");
191    ctx.log_warn("w");
192    assert_eq!(
193      host.logs(),
194      vec![(LogLevel::Debug, "d".to_string()), (LogLevel::Warn, "w".to_string())]
195    );
196  }
197
198  #[test]
199  fn get_config_parses_and_coerces() {
200    let host = MockHost::new().with_config("plugin:port", "\"8080\"");
201    let ctx = Context::new(&host, "/w".into(), "s".into());
202    assert_eq!(ctx.get_config("plugin:port"), Some(serde_json::json!("8080")));
203    assert_eq!(ctx.get_config_as::<i64>("plugin:port"), Some(8080));
204    assert_eq!(ctx.get_config_as::<i64>("missing"), None);
205  }
206
207  #[test]
208  fn state_downcasts() {
209    struct S {
210      n: u32,
211    }
212    let host = MockHost::new();
213    let s = S { n: 7 };
214    let ctx = Context::new(&host, "/w".into(), "s".into()).with_state(&s);
215    assert_eq!(ctx.state::<S>().n, 7);
216  }
217
218  #[test]
219  #[should_panic(expected = "this plugin declared no `state:` in moonlit_plugin!")]
220  fn state_panics_when_plugin_declared_none() {
221    struct S;
222    let host = MockHost::new();
223    let ctx = Context::new(&host, "/w".into(), "s".into());
224    let _ = ctx.state::<S>();
225  }
226
227  #[test]
228  #[should_panic(expected = "this plugin declared no `config:` in moonlit_plugin!")]
229  fn plugin_config_panics_when_plugin_declared_none() {
230    struct C;
231    let host = MockHost::new();
232    let ctx = Context::new(&host, "/w".into(), "s".into());
233    let _ = ctx.plugin_config::<C>();
234  }
235}