Skip to main content

forge_test_harness/
lib.rs

1//! Plugin author test harness.
2//!
3//! This is the *only* supported integration-test path for plugin authors.
4//! See ADR-0004. The harness drives `cargo build` (when asked) and loads
5//! the resulting `.wasm` through the same `forge-host` runtime that
6//! production uses.
7//!
8//! # Recommended usage
9//!
10//! ```ignore
11//! use forge_test_harness::PluginRunner;
12//!
13//! #[test]
14//! fn drops_unwanted_operations() {
15//!     let runner = PluginRunner::build_and_load(env!("CARGO_MANIFEST_DIR"))
16//!         .unwrap();
17//!     let out = runner
18//!         .transform(fixture_ir(), serde_json::json!({"keep": ["users"]}))
19//!         .unwrap();
20//!     assert_eq!(out.spec.operations.len(), 2);
21//! }
22//! ```
23//!
24//! The first invocation performs `cargo build --release --target
25//! wasm32-wasip2` for the plugin's manifest dir. Subsequent runs reuse
26//! cargo's incremental cache, so the cycle is fast in practice.
27//!
28//! The example above is `ignore`d because doctests can't easily build a
29//! `wasm32-wasip2` target on the fly. A smaller, executable doctest on
30//! [`HarnessError`] verifies that the published surface is reachable.
31
32#![forbid(unsafe_code)]
33
34use std::path::{Path, PathBuf};
35use std::process::Command;
36
37use forge_host::{Engine, GenerationOutput, Limits, Plugin, StageError, TransformOutput};
38use forge_ir::Ir;
39
40/// Anything that can go wrong while building or loading a plugin.
41///
42/// ```
43/// use forge_test_harness::HarnessError;
44///
45/// let err = HarnessError::Build("cargo exited 101".into());
46/// assert!(format!("{err}").contains("cargo exited 101"));
47/// ```
48#[derive(Debug, thiserror::Error)]
49pub enum HarnessError {
50    #[error("failed to build plugin: {0}")]
51    Build(String),
52    #[error("plugin io: {0}")]
53    Io(#[from] std::io::Error),
54    #[error("could not locate built .wasm under {0:?}")]
55    NotFound(PathBuf),
56    #[error("engine init: {0}")]
57    Engine(String),
58    #[error("plugin load: {0}")]
59    Load(String),
60}
61
62/// Loaded plugin handle, ready to invoke.
63#[derive(Debug)]
64pub struct PluginRunner {
65    engine: Engine,
66    plugin: Plugin,
67}
68
69impl PluginRunner {
70    /// Build the plugin from its Cargo manifest dir, then load the
71    /// resulting `.wasm`. Build invalidation is delegated to cargo.
72    pub fn build_and_load(manifest_dir: impl AsRef<Path>) -> Result<Self, HarnessError> {
73        let manifest_dir = manifest_dir.as_ref();
74        build(manifest_dir)?;
75        let wasm = locate_artifact(manifest_dir)?;
76        Self::load(wasm)
77    }
78
79    /// Load an already-built `.wasm`. The harness inspects the component's
80    /// exports and chooses transformer vs generator automatically.
81    pub fn load(wasm_path: impl AsRef<Path>) -> Result<Self, HarnessError> {
82        let bytes = std::fs::read(wasm_path.as_ref())?;
83        let engine = Engine::new().map_err(|e| HarnessError::Engine(e.to_string()))?;
84        match Plugin::load_transformer(&engine, &bytes) {
85            Ok(p) => Ok(Self { engine, plugin: p }),
86            Err(_) => {
87                let p = Plugin::load_generator(&engine, &bytes)
88                    .map_err(|e| HarnessError::Load(e.to_string()))?;
89                Ok(Self { engine, plugin: p })
90            }
91        }
92    }
93
94    pub fn info(&self) -> &forge_ir::PluginInfo {
95        self.plugin.info()
96    }
97
98    pub fn engine(&self) -> &Engine {
99        &self.engine
100    }
101
102    pub fn plugin(&self) -> &Plugin {
103        &self.plugin
104    }
105
106    pub fn transform(
107        &self,
108        ir: Ir,
109        config: serde_json::Value,
110    ) -> Result<TransformOutput, StageError> {
111        let s = config.to_string();
112        self.plugin.transform(ir, &s, Limits::transformer())
113    }
114
115    pub fn generate(
116        &self,
117        ir: Ir,
118        config: serde_json::Value,
119    ) -> Result<GenerationOutput, StageError> {
120        let s = config.to_string();
121        self.plugin.generate(ir, &s, Limits::generator())
122    }
123}
124
125fn build(manifest_dir: &Path) -> Result<(), HarnessError> {
126    let manifest = manifest_dir.join("Cargo.toml");
127    if !manifest.exists() {
128        return Err(HarnessError::Build(format!(
129            "no Cargo.toml at {}",
130            manifest.display()
131        )));
132    }
133    let status = Command::new(env!("CARGO"))
134        .args([
135            "build",
136            "--release",
137            "--target",
138            "wasm32-wasip2",
139            "--manifest-path",
140        ])
141        .arg(&manifest)
142        .status()
143        .map_err(|e| HarnessError::Build(format!("spawn cargo: {e}")))?;
144    if !status.success() {
145        return Err(HarnessError::Build(format!(
146            "cargo build exited with {status}"
147        )));
148    }
149    Ok(())
150}
151
152fn locate_artifact(manifest_dir: &Path) -> Result<PathBuf, HarnessError> {
153    let crate_name = read_crate_name(manifest_dir)?;
154    let underscore = crate_name.replace('-', "_");
155    let mut search = manifest_dir.to_path_buf();
156    loop {
157        let candidate = search
158            .join("target")
159            .join("wasm32-wasip2")
160            .join("release")
161            .join(format!("{underscore}.wasm"));
162        if candidate.exists() {
163            return Ok(candidate);
164        }
165        let Some(parent) = search.parent() else {
166            return Err(HarnessError::NotFound(
167                manifest_dir
168                    .join("target/wasm32-wasip2/release")
169                    .join(format!("{underscore}.wasm")),
170            ));
171        };
172        search = parent.to_path_buf();
173    }
174}
175
176fn read_crate_name(manifest_dir: &Path) -> Result<String, HarnessError> {
177    let manifest = std::fs::read_to_string(manifest_dir.join("Cargo.toml"))?;
178    // Tiny one-pass extraction of `name = "..."` from `[package]`. We don't
179    // pull in `toml` for this — the harness is meant to be a low-friction
180    // dependency.
181    let mut in_package = false;
182    for raw in manifest.lines() {
183        let line = raw.trim();
184        if line.starts_with('#') {
185            continue;
186        }
187        if line.starts_with('[') {
188            in_package = line == "[package]";
189            continue;
190        }
191        if in_package {
192            if let Some(rest) = line.strip_prefix("name") {
193                let rest = rest.trim_start_matches(|c: char| c.is_whitespace() || c == '=');
194                if let Some(name) = rest
195                    .trim()
196                    .strip_prefix('"')
197                    .and_then(|s| s.strip_suffix('"'))
198                {
199                    return Ok(name.to_string());
200                }
201            }
202        }
203    }
204    Err(HarnessError::Build(format!(
205        "no [package] name in {}",
206        manifest_dir.join("Cargo.toml").display()
207    )))
208}