use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const ABI_VERSION: u32 = 1;
const NO_MESSAGE: &str = "the plugin reported a failure with no message";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Concurrency {
#[default]
Shared,
PerWorker,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
pub name: String,
pub version: String,
pub groups: Vec<String>,
#[serde(default)]
pub concurrency: Concurrency,
#[serde(default)]
pub fields: BTreeMap<String, Vec<ConfigField>>,
#[serde(default)]
pub implicit_instance: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ConfigField {
pub name: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub example: Option<String>,
#[serde(default, rename = "type")]
pub value_type: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepKind {
Action,
Assertion,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StepSpec {
pub pattern: String,
pub group: String,
pub kind: StepKind,
#[serde(default)]
pub description: Option<String>,
}
impl StepSpec {
pub fn is_assertion(&self) -> bool {
matches!(self.kind, StepKind::Assertion)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PollingJson {
pub timeout_secs: u64,
pub interval_ms: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct OptionsJson {
pub polling: PollingJson,
}
impl From<&crate::options::Options> for OptionsJson {
fn from(options: &crate::options::Options) -> Self {
Self {
polling: PollingJson {
timeout_secs: options.polling.timeout.as_secs(),
interval_ms: options.polling.interval.as_millis() as u64,
},
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct InitRequest<'a> {
pub group: &'a str,
pub instance: &'a str,
pub config: &'a serde_json::Value,
pub options: OptionsJson,
}
#[derive(Debug, Clone, Serialize)]
pub struct DispatchRequest<'a> {
pub args: &'a [String],
pub docstring: Option<&'a String>,
pub table: Option<&'a Vec<Vec<String>>>,
pub artifacts_dir: String,
pub workspace_dir: String,
pub debug: bool,
pub options: OptionsJson,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Passed,
NotYet,
Fatal,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Diagnostic {
pub title: String,
pub kind: String,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DispatchResult {
pub status: Status,
#[serde(default)]
pub vars: BTreeMap<String, String>,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
#[serde(default)]
pub error: Option<String>,
}
impl DispatchResult {
pub fn render_failure(&self) -> String {
let mut out = self.error.clone().unwrap_or_else(|| NO_MESSAGE.to_string());
for d in &self.diagnostics {
out.push_str(&format!("\n\n--- {} ({}) ---", d.title, d.kind));
if let Some(content) = &d.content {
out.push('\n');
out.push_str(content);
}
if let Some(path) = &d.path {
out.push_str(&format!("\n{path}"));
}
}
out
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Envelope {
pub ok: bool,
#[serde(default)]
pub error: Option<String>,
}
impl Envelope {
pub fn into_result(self) -> Result<(), String> {
if self.ok {
Ok(())
} else {
Err(self.error.unwrap_or_else(|| NO_MESSAGE.to_string()))
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct InitResponse {
pub ok: bool,
#[serde(default)]
pub handle: Option<u64>,
#[serde(default)]
pub error: Option<String>,
}
impl InitResponse {
pub fn into_result(self) -> Result<u64, String> {
if self.ok {
self.handle
.ok_or_else(|| "the plugin reported ok with no handle".to_string())
} else {
Err(self
.error
.unwrap_or_else(|| "the plugin refused to create the instance".to_string()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_manifest_parses() {
let m: Manifest = serde_json::from_str(
r#"{"name":"widget","version":"1.2.0","groups":["widget"],"concurrency":"shared"}"#,
)
.expect("manifest parses");
assert_eq!(m.name, "widget");
assert_eq!(m.groups, vec!["widget".to_string()]);
assert_eq!(m.concurrency, Concurrency::Shared);
}
#[test]
fn concurrency_defaults_to_shared() {
let m: Manifest = serde_json::from_str(r#"{"name":"x","version":"0.1.0","groups":["x"]}"#)
.expect("manifest parses");
assert_eq!(m.concurrency, Concurrency::Shared);
}
#[test]
fn a_step_spec_parses_both_kinds() {
let steps: Vec<StepSpec> = serde_json::from_str(
r#"[{"pattern":"^a$","group":"widget","kind":"action"},
{"pattern":"^b$","group":"widget","kind":"assertion"}]"#,
)
.expect("steps parse");
assert!(!steps[0].is_assertion());
assert!(steps[1].is_assertion());
assert_eq!(steps[0].group, "widget");
}
#[test]
fn a_step_description_is_optional_in_both_directions() {
let plain: Vec<StepSpec> =
serde_json::from_str(r#"[{"pattern":"^a$","group":"widget","kind":"action"}]"#)
.expect("steps parse");
assert!(plain[0].description.is_none());
let described: Vec<StepSpec> = serde_json::from_str(
r#"[{"pattern":"^a$","group":"widget","kind":"action","description":"does a"}]"#,
)
.expect("steps parse");
assert_eq!(described[0].description.as_deref(), Some("does a"));
}
#[test]
fn a_dispatch_result_parses_with_defaults() {
let r: DispatchResult =
serde_json::from_str(r#"{"status":"passed"}"#).expect("result parses");
assert_eq!(r.status, Status::Passed);
assert!(r.vars.is_empty());
assert!(r.diagnostics.is_empty());
assert!(r.error.is_none());
}
#[test]
fn diagnostics_render_into_the_failure_text() {
let r = DispatchResult {
status: Status::Fatal,
vars: Default::default(),
diagnostics: vec![
Diagnostic {
title: "PUT /b/o".into(),
kind: "http".into(),
content: Some("403 Forbidden".into()),
path: None,
},
Diagnostic {
title: "Screenshot".into(),
kind: "image".into(),
content: None,
path: Some("/run/artifacts/7/fail.png".into()),
},
],
error: Some("access denied".into()),
};
let text = r.render_failure();
assert!(text.contains("access denied"), "{text}");
assert!(text.contains("PUT /b/o"), "{text}");
assert!(text.contains("403 Forbidden"), "{text}");
assert!(text.contains("/run/artifacts/7/fail.png"), "{text}");
}
#[test]
fn an_envelope_carries_the_error() {
let e: Envelope = serde_json::from_str(r#"{"ok":false,"error":"bucket is required"}"#)
.expect("envelope parses");
assert_eq!(e.into_result().unwrap_err(), "bucket is required");
}
#[test]
fn an_envelope_without_a_message_still_fails() {
let e: Envelope = serde_json::from_str(r#"{"ok":false}"#).expect("envelope parses");
assert!(e.into_result().is_err());
}
#[test]
fn an_init_response_yields_a_handle() {
let r: InitResponse =
serde_json::from_str(r#"{"ok":true,"handle":7}"#).expect("init response parses");
assert_eq!(r.into_result().expect("ok"), 7);
}
#[test]
fn an_init_response_ok_with_no_handle_is_an_error() {
let r: InitResponse = serde_json::from_str(r#"{"ok":true}"#).expect("init response parses");
let err = r.into_result().unwrap_err();
assert!(err.contains("handle"), "{err}");
}
#[test]
fn a_dispatch_request_serialises_the_documented_keys() {
let args = vec!["report.pdf".to_string()];
let request = DispatchRequest {
args: &args,
docstring: None,
table: None,
artifacts_dir: "/tmp/run/0007".into(),
workspace_dir: "/tmp/run/workspace/000000".into(),
debug: false,
options: OptionsJson::from(&crate::options::Options::default()),
};
let value = serde_json::to_value(&request).expect("serialises");
let mut keys: Vec<&String> = value.as_object().expect("an object").keys().collect();
keys.sort();
let mut expected = [
"args",
"docstring",
"table",
"artifacts_dir",
"workspace_dir",
"debug",
"options",
];
expected.sort();
assert_eq!(keys, expected);
assert!(
value["docstring"].is_null(),
"an absent docstring stays present as null, as the documented payload shows"
);
assert_eq!(value["options"]["polling"]["interval_ms"], 100);
}
#[test]
fn an_init_request_serialises_the_documented_keys_and_config_verbatim() {
let config = serde_json::json!({"bucket": "acme-uploads", "region": "eu-west-1"});
let request = InitRequest {
group: "widget",
instance: "primary",
config: &config,
options: OptionsJson::from(&crate::options::Options::default()),
};
let value = serde_json::to_value(&request).expect("serialises");
let mut keys: Vec<&String> = value.as_object().expect("an object").keys().collect();
keys.sort();
let mut expected = ["group", "instance", "config", "options"];
expected.sort();
assert_eq!(keys, expected);
assert_eq!(value["config"], config);
}
#[test]
fn an_unknown_field_from_a_newer_plugin_is_ignored() {
let manifest: Manifest = serde_json::from_str(
r#"{"name":"widget","version":"1.0.0","groups":["widget"],"capabilities":["streaming"]}"#,
)
.expect("a newer plugin's extra keys do not break an older host");
assert_eq!(manifest.name, "widget");
}
#[test]
fn a_manifest_declares_the_config_fields_of_each_group() {
let m: Manifest = serde_json::from_str(
r#"{"name":"s3","version":"1.2.0","groups":["s3"],"fields":{
"s3":[{"name":"bucket","required":true,
"description":"bucket the steps read and write","example":"acceptance"},
{"name":"endpoint","description":"S3-compatible endpoint; omit for AWS"}]}}"#,
)
.expect("manifest parses");
let s3 = &m.fields["s3"];
assert_eq!(s3[0].name, "bucket");
assert!(s3[0].required);
assert_eq!(
s3[0].description.as_deref(),
Some("bucket the steps read and write")
);
assert_eq!(s3[0].example.as_deref(), Some("acceptance"));
assert_eq!(s3[1].name, "endpoint");
assert!(!s3[1].required, "required defaults to false");
assert!(s3[1].example.is_none());
}
#[test]
fn a_manifest_without_fields_parses_to_an_empty_map() {
let m: Manifest = serde_json::from_str(r#"{"name":"x","version":"0.1.0","groups":["x"]}"#)
.expect("manifest parses");
assert!(m.fields.is_empty());
}
}