merge: sync orchestration before queue 00001KVHR3WRF
This commit is contained in:
@@ -15,6 +15,42 @@ const ZIP_COMPRESSION_STORED: u16 = 0;
|
||||
const ZIP_UNIX_SYMLINK_TYPE: u32 = 0o120000;
|
||||
const ZIP_UNIX_FILE_TYPE_MASK: u32 = 0o170000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PluginTemplateResource {
|
||||
pub path: &'static str,
|
||||
pub contents: &'static str,
|
||||
}
|
||||
|
||||
/// Embedded starter template for Rust Component Model Tool Plugins.
|
||||
///
|
||||
/// The template is data only: it performs no filesystem/network operations and
|
||||
/// grants no authority. Future authoring CLI commands can materialize these
|
||||
/// files into a chosen destination after applying their own overwrite policy.
|
||||
pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[
|
||||
PluginTemplateResource {
|
||||
path: "Cargo.toml",
|
||||
contents: include_str!(
|
||||
"../../../resources/plugin/templates/rust-component-tool/Cargo.toml"
|
||||
),
|
||||
},
|
||||
PluginTemplateResource {
|
||||
path: "src/lib.rs",
|
||||
contents: include_str!(
|
||||
"../../../resources/plugin/templates/rust-component-tool/src/lib.rs"
|
||||
),
|
||||
},
|
||||
PluginTemplateResource {
|
||||
path: "plugin.toml",
|
||||
contents: include_str!(
|
||||
"../../../resources/plugin/templates/rust-component-tool/plugin.toml"
|
||||
),
|
||||
},
|
||||
PluginTemplateResource {
|
||||
path: "README.md",
|
||||
contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"),
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PluginConfig {
|
||||
@@ -1957,6 +1993,40 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn embedded_rust_component_tool_template_is_valid_package_shape() {
|
||||
let paths: BTreeSet<_> = RUST_COMPONENT_TOOL_TEMPLATE
|
||||
.iter()
|
||||
.map(|file| file.path)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
paths,
|
||||
BTreeSet::from(["Cargo.toml", "src/lib.rs", "plugin.toml", "README.md"])
|
||||
);
|
||||
assert!(
|
||||
RUST_COMPONENT_TOOL_TEMPLATE
|
||||
.iter()
|
||||
.all(|file| !file.path.starts_with('/') && !file.path.contains(".."))
|
||||
);
|
||||
|
||||
let manifest_text = RUST_COMPONENT_TOOL_TEMPLATE
|
||||
.iter()
|
||||
.find(|file| file.path == "plugin.toml")
|
||||
.unwrap()
|
||||
.contents;
|
||||
let manifest: PluginPackageManifest = toml::from_str(manifest_text).unwrap();
|
||||
assert_eq!(manifest.schema_version, SUPPORTED_PLUGIN_API_VERSION);
|
||||
assert_eq!(
|
||||
manifest.runtime.as_ref().unwrap().kind,
|
||||
PLUGIN_RUNTIME_COMPONENT_KIND
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.runtime.as_ref().unwrap().world.as_deref(),
|
||||
Some(PLUGIN_COMPONENT_TOOL_WORLD)
|
||||
);
|
||||
assert_eq!(manifest.tools.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_valid_user_and_workspace_packages() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "yoi-plugin-pdk"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Guest-side Rust PDK helpers for Yoi Component Model Tool plugins"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
wit-bindgen = "0.51.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
toml.workspace = true
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Guest-side helpers for Yoi Component Model Tool plugins.
|
||||
//!
|
||||
//! This crate is intentionally small and guest-only: it depends on JSON/WIT
|
||||
//! binding support, but not on Yoi host/runtime crates. It grants no authority;
|
||||
//! package manifests and host-side Plugin grants decide whether a Tool or host
|
||||
//! API can run.
|
||||
//!
|
||||
//! Component authors still generate the WIT bindings in their guest crate, then
|
||||
//! delegate the exported `call` function to [`run_json_tool`] or to the
|
||||
//! [`export_component_tool!`] macro:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use yoi_plugin_pdk::wit_bindgen;
|
||||
//!
|
||||
//! wit_bindgen::generate!({
|
||||
//! world: "tool",
|
||||
//! path: "../../../../resources/plugin/wit",
|
||||
//! generate_all,
|
||||
//! runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
//! });
|
||||
//!
|
||||
//! fn echo(
|
||||
//! ctx: yoi_plugin_pdk::ToolContext,
|
||||
//! input: EchoInput,
|
||||
//! ) -> Result<yoi_plugin_pdk::ToolOutput, yoi_plugin_pdk::ToolError> {
|
||||
//! yoi_plugin_pdk::ToolOutput::json(
|
||||
//! format!("{} ok", ctx.tool_name()),
|
||||
//! EchoOutput { text: input.text },
|
||||
//! )
|
||||
//! }
|
||||
//!
|
||||
//! yoi_plugin_pdk::export_component_tool!(Plugin, echo);
|
||||
//! ```
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
pub use wit_bindgen;
|
||||
|
||||
/// Current Yoi Component Model Tool world targeted by this PDK.
|
||||
pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0";
|
||||
|
||||
/// Repository WIT for the current Tool world, exposed for authoring tools and
|
||||
/// tests. Runtime components should still generate bindings at compile time.
|
||||
pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-tool-v1.wit");
|
||||
|
||||
/// Repository WIT for the grant-bound host APIs importable by Tool components.
|
||||
pub const HOST_WIT: &str =
|
||||
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit");
|
||||
|
||||
/// Maximum serialized ToolOutput JSON accepted by Yoi's current Plugin runtime.
|
||||
pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024;
|
||||
/// Maximum summary bytes accepted by Yoi's current Plugin runtime.
|
||||
pub const MAX_SUMMARY_BYTES: usize = 1024;
|
||||
/// Conservative content cap that leaves room for JSON framing and escaping.
|
||||
pub const MAX_CONTENT_BYTES: usize = MAX_TOOL_OUTPUT_BYTES - MAX_SUMMARY_BYTES - 4096;
|
||||
/// Maximum structured error message bytes retained by the PDK.
|
||||
pub const MAX_ERROR_MESSAGE_BYTES: usize = 4096;
|
||||
|
||||
/// Per-call context passed to a typed JSON handler.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ToolContext {
|
||||
tool_name: String,
|
||||
}
|
||||
|
||||
impl ToolContext {
|
||||
/// Create context for the manifest-declared Tool name selected by the host.
|
||||
pub fn new(tool_name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_name: bounded_text(tool_name.into(), MAX_SUMMARY_BYTES),
|
||||
}
|
||||
}
|
||||
|
||||
/// Manifest-declared Tool name supplied by the host runtime.
|
||||
pub fn tool_name(&self) -> &str {
|
||||
&self.tool_name
|
||||
}
|
||||
}
|
||||
|
||||
/// ToolOutput JSON shape accepted by the current Yoi Plugin runtime.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct ToolOutput {
|
||||
summary: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolOutput {
|
||||
/// Create an ordinary Tool output.
|
||||
pub fn new(summary: impl Into<String>, content: Option<String>) -> Self {
|
||||
let mut output = Self {
|
||||
summary: normalize_summary(summary.into()),
|
||||
content: content.map(|value| bounded_text(value, MAX_CONTENT_BYTES)),
|
||||
};
|
||||
output.bound_serialized_json();
|
||||
output
|
||||
}
|
||||
|
||||
/// Create a Tool output whose content is typed JSON.
|
||||
pub fn json(summary: impl Into<String>, value: impl Serialize) -> Result<Self, ToolError> {
|
||||
let content = serde_json::to_string(&value).map_err(ToolError::serialization)?;
|
||||
let output = Self {
|
||||
summary: normalize_summary(summary.into()),
|
||||
content: Some(content),
|
||||
};
|
||||
let serialized = serde_json::to_string(&output).map_err(ToolError::serialization)?;
|
||||
if serialized.len() > MAX_TOOL_OUTPUT_BYTES {
|
||||
return Err(ToolError::invalid_output(format!(
|
||||
"serialized ToolOutput JSON exceeds {MAX_TOOL_OUTPUT_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Create a summary-only Tool output.
|
||||
pub fn summary(summary: impl Into<String>) -> Self {
|
||||
Self::new(summary, None)
|
||||
}
|
||||
|
||||
/// Return the bounded summary.
|
||||
pub fn summary_text(&self) -> &str {
|
||||
&self.summary
|
||||
}
|
||||
|
||||
/// Return optional detailed content.
|
||||
pub fn content(&self) -> Option<&str> {
|
||||
self.content.as_deref()
|
||||
}
|
||||
|
||||
/// Serialize to the ToolOutput JSON string returned by the WIT `call` export.
|
||||
pub fn to_json_string(&self) -> String {
|
||||
serde_json::to_string(self).unwrap_or_else(|_| {
|
||||
r#"{"summary":"tool error: serialization","content":"{\"error\":{\"code\":\"serialization\",\"message\":\"failed to serialize ToolOutput\"}}"}"#.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn bound_serialized_json(&mut self) {
|
||||
loop {
|
||||
let Ok(serialized) = serde_json::to_string(self) else {
|
||||
return;
|
||||
};
|
||||
if serialized.len() <= MAX_TOOL_OUTPUT_BYTES {
|
||||
return;
|
||||
}
|
||||
let Some(content) = self.content.take() else {
|
||||
return;
|
||||
};
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let overflow = serialized.len() - MAX_TOOL_OUTPUT_BYTES;
|
||||
let target = content.len().saturating_sub(overflow + "…".len());
|
||||
self.content = Some(bounded_text(content, target));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable, low-cardinality error codes for PDK-produced Tool errors.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolErrorCode {
|
||||
InvalidInput,
|
||||
InvalidOutput,
|
||||
Serialization,
|
||||
Denied,
|
||||
UnsupportedTool,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl ToolErrorCode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidInput => "invalid_input",
|
||||
Self::InvalidOutput => "invalid_output",
|
||||
Self::Serialization => "serialization",
|
||||
Self::Denied => "denied",
|
||||
Self::UnsupportedTool => "unsupported_tool",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured, bounded error that is rendered as ordinary ToolOutput JSON.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, thiserror::Error)]
|
||||
#[error("{code:?}: {message}")]
|
||||
pub struct ToolError {
|
||||
code: ToolErrorCode,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
details: Option<Value>,
|
||||
}
|
||||
|
||||
impl ToolError {
|
||||
pub fn new(code: ToolErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: bounded_text(message.into(), MAX_ERROR_MESSAGE_BYTES),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid_input(message: impl Into<String>) -> Self {
|
||||
Self::new(ToolErrorCode::InvalidInput, message)
|
||||
}
|
||||
|
||||
pub fn invalid_output(message: impl Into<String>) -> Self {
|
||||
Self::new(ToolErrorCode::InvalidOutput, message)
|
||||
}
|
||||
|
||||
pub fn serialization(error: impl std::fmt::Display) -> Self {
|
||||
Self::new(ToolErrorCode::Serialization, error.to_string())
|
||||
}
|
||||
|
||||
pub fn denied(message: impl Into<String>) -> Self {
|
||||
Self::new(ToolErrorCode::Denied, message)
|
||||
}
|
||||
|
||||
pub fn unsupported_tool(tool_name: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
ToolErrorCode::UnsupportedTool,
|
||||
format!("unsupported tool `{}`", tool_name.into()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn failed(message: impl Into<String>) -> Self {
|
||||
Self::new(ToolErrorCode::Failed, message)
|
||||
}
|
||||
|
||||
pub fn with_details(mut self, details: impl Serialize) -> Self {
|
||||
self.details = serde_json::to_value(details).ok();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn code(&self) -> ToolErrorCode {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn code_str(&self) -> &'static str {
|
||||
self.code.as_str()
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// Render this error as ordinary ToolOutput JSON content.
|
||||
pub fn into_tool_output(self) -> ToolOutput {
|
||||
#[derive(Serialize)]
|
||||
struct ErrorBody<'a> {
|
||||
error: ErrorPayload<'a>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorPayload<'a> {
|
||||
code: &'static str,
|
||||
message: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
details: Option<&'a Value>,
|
||||
}
|
||||
|
||||
let summary = format!("tool error: {}", self.code.as_str());
|
||||
let payload = ErrorBody {
|
||||
error: ErrorPayload {
|
||||
code: self.code.as_str(),
|
||||
message: &self.message,
|
||||
details: self.details.as_ref(),
|
||||
},
|
||||
};
|
||||
let mut content = serde_json::to_string(&payload).unwrap_or_else(|_| {
|
||||
format!(
|
||||
r#"{{"error":{{"code":"{}","message":"failed to serialize error details"}}}}"#,
|
||||
ToolErrorCode::Serialization.as_str()
|
||||
)
|
||||
});
|
||||
if content.len() > MAX_CONTENT_BYTES {
|
||||
let payload = ErrorBody {
|
||||
error: ErrorPayload {
|
||||
code: self.code.as_str(),
|
||||
message: &self.message,
|
||||
details: None,
|
||||
},
|
||||
};
|
||||
content = serde_json::to_string(&payload).unwrap_or_else(|_| {
|
||||
r#"{"error":{"code":"serialization","message":"failed to serialize error"}}"#
|
||||
.to_string()
|
||||
});
|
||||
}
|
||||
ToolOutput::new(summary, Some(content))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the WIT `input-json` string into a typed input value.
|
||||
pub fn parse_json_input<T>(input_json: &str) -> Result<T, ToolError>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
serde_json::from_str(input_json).map_err(|error| {
|
||||
ToolError::invalid_input(format!(
|
||||
"tool input is not valid JSON for this schema: {error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a typed JSON Tool handler and return ToolOutput JSON for the runtime.
|
||||
///
|
||||
/// Handler errors and parse/serialization failures are not panics or host-side
|
||||
/// authority decisions. They are rendered into ordinary ToolOutput JSON so the
|
||||
/// runtime can route them through the normal Tool result path.
|
||||
pub fn run_json_tool<I, F>(tool_name: &str, input_json: &str, handler: F) -> String
|
||||
where
|
||||
I: DeserializeOwned,
|
||||
F: FnOnce(ToolContext, I) -> Result<ToolOutput, ToolError>,
|
||||
{
|
||||
let result = parse_json_input::<I>(input_json).and_then(|input| {
|
||||
let context = ToolContext::new(tool_name);
|
||||
handler(context, input)
|
||||
});
|
||||
match result {
|
||||
Ok(output) => output.to_json_string(),
|
||||
Err(error) => error.into_tool_output().to_json_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement the generated Component Model `Guest` trait for a typed JSON
|
||||
/// handler and export it with the `wit-bindgen` generated `export!` macro.
|
||||
///
|
||||
/// The caller must import the PDK's `wit_bindgen` re-export and invoke
|
||||
/// `wit_bindgen::generate!` for the `tool` world first, with
|
||||
/// `runtime_path: "yoi_plugin_pdk::wit_bindgen::rt"`. That defines the
|
||||
/// `Guest` trait and `export!` macro in the current module. The generated
|
||||
/// component still imports only WIT-declared
|
||||
/// host APIs; this macro does not grant filesystem, network, or environment
|
||||
/// authority.
|
||||
#[macro_export]
|
||||
macro_rules! export_component_tool {
|
||||
($adapter:ident, $handler:path) => {
|
||||
struct $adapter;
|
||||
|
||||
impl Guest for $adapter {
|
||||
fn call(
|
||||
tool_name: ::std::string::String,
|
||||
input_json: ::std::string::String,
|
||||
) -> ::std::string::String {
|
||||
$crate::run_json_tool(&tool_name, &input_json, $handler)
|
||||
}
|
||||
}
|
||||
|
||||
export!($adapter);
|
||||
};
|
||||
}
|
||||
|
||||
fn normalize_summary(summary: String) -> String {
|
||||
let summary = bounded_text(summary, MAX_SUMMARY_BYTES);
|
||||
if summary.trim().is_empty() {
|
||||
"tool completed".to_string()
|
||||
} else {
|
||||
summary
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_text(mut value: String, max_bytes: usize) -> String {
|
||||
if max_bytes == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
value = value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_control() && ch != '\n' && ch != '\t' {
|
||||
' '
|
||||
} else {
|
||||
ch
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if value.len() <= max_bytes {
|
||||
return value;
|
||||
}
|
||||
|
||||
let suffix = "…";
|
||||
let budget = max_bytes.saturating_sub(suffix.len());
|
||||
let mut cut = budget.min(value.len());
|
||||
while cut > 0 && !value.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
value.truncate(cut);
|
||||
value.push_str(suffix);
|
||||
value
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EchoInput {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct EchoOutput<'a> {
|
||||
tool: &'a str,
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_json_tool_parses_input_and_serializes_output() {
|
||||
let output = run_json_tool(
|
||||
"example_echo",
|
||||
r#"{"text":"hello"}"#,
|
||||
|ctx, input: EchoInput| {
|
||||
ToolOutput::json(
|
||||
format!("{} ok", ctx.tool_name()),
|
||||
EchoOutput {
|
||||
tool: ctx.tool_name(),
|
||||
text: input.text,
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
let value: Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(value["summary"], "example_echo ok");
|
||||
let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(content, json!({"tool":"example_echo","text":"hello"}));
|
||||
assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_json_tool_renders_error_output() {
|
||||
let output =
|
||||
run_json_tool::<EchoInput, _>("example_echo", r#"{"text":1}"#, |_ctx, _input| {
|
||||
Ok(ToolOutput::summary("unreachable"))
|
||||
});
|
||||
|
||||
let value: Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(value["summary"], "tool error: invalid_input");
|
||||
let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(content["error"]["code"], "invalid_input");
|
||||
assert!(content["error"]["message"].as_str().unwrap().len() <= MAX_ERROR_MESSAGE_BYTES);
|
||||
assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_output_rejects_oversized_serialized_tool_output() {
|
||||
let too_large = vec!["quoted \" text"; MAX_TOOL_OUTPUT_BYTES / 4];
|
||||
let error = ToolOutput::json("too large", too_large).unwrap_err();
|
||||
|
||||
assert_eq!(error.code(), ToolErrorCode::InvalidOutput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_error_bounds_message_and_control_characters() {
|
||||
let message = format!("bad\u{0007}{}", "x".repeat(MAX_ERROR_MESSAGE_BYTES * 2));
|
||||
let error = ToolError::failed(message);
|
||||
|
||||
assert_eq!(error.code_str(), "failed");
|
||||
assert!(!error.message().contains('\u{0007}'));
|
||||
assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES + "…".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_constants_match_current_world() {
|
||||
assert!(TOOL_WIT.contains("package yoi:plugin@1.0.0"));
|
||||
assert!(TOOL_WIT.contains("world tool"));
|
||||
assert!(TOOL_WIT.contains("export call"));
|
||||
assert_eq!(TOOL_WORLD, "yoi:plugin/tool@1.0.0");
|
||||
assert!(HOST_WIT.contains("interface https"));
|
||||
assert!(HOST_WIT.contains("interface fs"));
|
||||
assert!(HOST_WIT.contains("%list: func"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use toml::Value;
|
||||
|
||||
const TEMPLATE_CARGO: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/Cargo.toml");
|
||||
const TEMPLATE_LIB: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/src/lib.rs");
|
||||
const TEMPLATE_PLUGIN: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml");
|
||||
const TEMPLATE_README: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/README.md");
|
||||
const SAMPLE_LIB: &str = include_str!("../../../docs/examples/plugin-component-tool/lib.rs");
|
||||
const PDK_CARGO: &str = include_str!("../Cargo.toml");
|
||||
|
||||
#[test]
|
||||
fn rust_component_tool_template_has_expected_files() {
|
||||
let cargo: Value = toml::from_str(TEMPLATE_CARGO).expect("template Cargo.toml parses");
|
||||
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
|
||||
assert!(cargo["workspace"].is_table());
|
||||
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
|
||||
assert_eq!(
|
||||
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
|
||||
Some("../../../../crates/plugin-pdk")
|
||||
);
|
||||
assert!(TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-revision>\""));
|
||||
|
||||
let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses");
|
||||
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
|
||||
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
|
||||
assert_eq!(
|
||||
plugin["runtime"]["world"].as_str(),
|
||||
Some("yoi:plugin/tool@1.0.0")
|
||||
);
|
||||
assert!(plugin["tools"].as_array().expect("tools array").len() == 1);
|
||||
|
||||
assert!(TEMPLATE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
|
||||
assert!(TEMPLATE_LIB.contains("wit_bindgen::generate!"));
|
||||
assert!(TEMPLATE_LIB.contains("generate_all"));
|
||||
assert!(TEMPLATE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
|
||||
assert!(TEMPLATE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
|
||||
assert!(TEMPLATE_LIB.contains("ToolOutput::json"));
|
||||
assert!(TEMPLATE_README.contains("Component Model Tool Plugin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documented_sample_uses_pdk_component_path() {
|
||||
assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
|
||||
assert!(SAMPLE_LIB.contains("wit_bindgen::generate!"));
|
||||
assert!(SAMPLE_LIB.contains("generate_all"));
|
||||
assert!(SAMPLE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
|
||||
assert!(SAMPLE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
|
||||
assert!(!SAMPLE_LIB.contains("export_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_template_cargo_checks_for_wasm_target() {
|
||||
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let template_dir = crate_dir.join("../../resources/plugin/templates/rust-component-tool");
|
||||
let manifest_path = template_dir.join("Cargo.toml");
|
||||
let lock_path = template_dir.join("Cargo.lock");
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
|
||||
let target_dir = tempfile::tempdir().expect("temporary cargo target dir");
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
|
||||
let output = Command::new(cargo)
|
||||
.arg("check")
|
||||
.arg("--manifest-path")
|
||||
.arg(&manifest_path)
|
||||
.arg("--target")
|
||||
.arg("wasm32-unknown-unknown")
|
||||
.arg("--offline")
|
||||
.arg("--target-dir")
|
||||
.arg(target_dir.path())
|
||||
.env("CARGO_TERM_COLOR", "never")
|
||||
.output()
|
||||
.expect("spawn cargo check for embedded template");
|
||||
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"template cargo check failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdk_runtime_dependencies_are_guest_side_only() {
|
||||
let cargo: Value = toml::from_str(PDK_CARGO).expect("PDK Cargo.toml parses");
|
||||
let dependencies = cargo["dependencies"]
|
||||
.as_table()
|
||||
.expect("dependencies table");
|
||||
let forbidden = [
|
||||
"pod",
|
||||
"yoi-pod",
|
||||
"llm-worker",
|
||||
"tui",
|
||||
"yoi-tui",
|
||||
"client",
|
||||
"yoi-client",
|
||||
"manifest",
|
||||
"yoi-manifest",
|
||||
"ticket",
|
||||
"yoi-ticket",
|
||||
];
|
||||
|
||||
for name in forbidden {
|
||||
assert!(
|
||||
!dependencies.contains_key(name),
|
||||
"PDK must not depend on host/runtime crate `{name}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use yoi_plugin_pdk::wit_bindgen;
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "tool",
|
||||
path: "../../resources/plugin/wit",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
|
||||
struct Probe;
|
||||
|
||||
impl Guest for Probe {
|
||||
fn call(tool_name: String, input_json: String) -> String {
|
||||
yoi_plugin_pdk::run_json_tool(&tool_name, &input_json, |_ctx, input: serde_json::Value| {
|
||||
yoi_plugin_pdk::ToolOutput::json("probe ok", input)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_bindgen_generates_current_tool_world() {
|
||||
let output = <Probe as Guest>::call("probe".to_string(), r#"{"ok":true}"#.to_string());
|
||||
let value: serde_json::Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(value["summary"], "probe ok");
|
||||
}
|
||||
@@ -44,6 +44,7 @@ dotenv = "0.15.0"
|
||||
futures = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
wat = "1.241.2"
|
||||
yoi-plugin-pdk = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
toml = { workspace = true }
|
||||
|
||||
@@ -3868,6 +3868,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdk_tool_output_shape_is_accepted_by_wasm_decoder() {
|
||||
let pdk_output =
|
||||
yoi_plugin_pdk::ToolOutput::json("pdk ok", serde_json::json!({"answer": 42}))
|
||||
.unwrap()
|
||||
.to_json_string();
|
||||
|
||||
let output = decode_plugin_wasm_output(pdk_output.as_bytes()).unwrap();
|
||||
assert_eq!(output.summary, "pdk ok");
|
||||
assert_eq!(output.content.as_deref(), Some(r#"{"answer":42}"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_input_json_fails_before_wasm_execution() {
|
||||
let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module());
|
||||
|
||||
Reference in New Issue
Block a user