use std::fmt;
use serde_json::{Map, Value};
use crate::diag::{Diagnostic, Origin, Rule};
use crate::name::{InvalidName, PluginName};
pub const PLUGIN_SCHEMA_1_0_0: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SpecVersion {
V1_0_0,
}
impl fmt::Display for SpecVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("1.0.0")
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Manifest {
pub spec: SpecVersion,
pub name: PluginName,
pub version: Option<String>,
pub description: Option<String>,
pub author: Option<Author>,
pub homepage: Option<String>,
pub repository: Option<String>,
pub license: Option<String>,
pub keywords: Vec<String>,
pub extensions: Extensions,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct Author {
pub name: Option<String>,
pub email: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Extensions {
entries: Map<String, Value>,
}
impl Extensions {
pub fn namespace(&self, ns: &str) -> Option<&Map<String, Value>> {
self.entries.get(ns).and_then(Value::as_object)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &Map<String, Value>)> {
self.entries.iter().filter_map(|(ns, v)| v.as_object().map(|obj| (ns.as_str(), obj)))
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ManifestRejection {
NotJson {
detail: String,
},
NotAnObject,
MissingSchema,
UnsupportedSchema {
declared: String,
},
MissingName,
InvalidName(InvalidName),
SchemaViolation {
field: String,
expected: &'static str,
},
}
impl fmt::Display for ManifestRejection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotJson { detail } => write!(f, "plugin.json is not valid JSON: {detail}"),
Self::NotAnObject => f.write_str("plugin.json must contain a top-level JSON object"),
Self::MissingSchema => {
f.write_str("required field `$schema` is missing or not a string (§5.3)")
}
Self::UnsupportedSchema { declared } => write!(
f,
"`$schema` declares an unsupported Agent Plugins version: {declared} (§5.2)"
),
Self::MissingName => {
f.write_str("required field `name` is missing or not a string (§5.3)")
}
Self::InvalidName(err) => write!(f, "invalid plugin name (§5.5): {err}"),
Self::SchemaViolation { field, expected } => {
write!(f, "field `{field}` must be {expected} (§5.2)")
}
}
}
}
impl std::error::Error for ManifestRejection {}
impl Manifest {
pub fn parse(bytes: &[u8]) -> Result<(Self, Vec<Diagnostic>), ManifestRejection> {
let document: Value = serde_json::from_slice(bytes)
.map_err(|e| ManifestRejection::NotJson { detail: e.to_string() })?;
let Value::Object(object) = document else {
return Err(ManifestRejection::NotAnObject);
};
let spec = match object.get("$schema") {
Some(Value::String(id)) if id == PLUGIN_SCHEMA_1_0_0 => SpecVersion::V1_0_0,
Some(Value::String(other)) => {
return Err(ManifestRejection::UnsupportedSchema { declared: other.clone() })
}
_ => return Err(ManifestRejection::MissingSchema),
};
let name = match object.get("name") {
Some(Value::String(raw)) => {
PluginName::parse(raw).map_err(ManifestRejection::InvalidName)?
}
_ => return Err(ManifestRejection::MissingName),
};
let mut diagnostics = Vec::new();
let mut manifest = Manifest {
spec,
name,
version: None,
description: None,
author: None,
homepage: None,
repository: None,
license: None,
keywords: Vec::new(),
extensions: Extensions::default(),
};
for (field, value) in &object {
match field.as_str() {
"$schema" | "name" => {}
"version" => manifest.version = Some(expect_string(field, value)?),
"description" => manifest.description = Some(expect_string(field, value)?),
"homepage" => manifest.homepage = Some(expect_string(field, value)?),
"repository" => manifest.repository = Some(expect_string(field, value)?),
"license" => manifest.license = Some(expect_string(field, value)?),
"author" => manifest.author = Some(parse_author(value)?),
"keywords" => manifest.keywords = parse_keywords(value)?,
"extensions" => match value {
Value::Object(entries) => {
for (ns, data) in entries {
if !data.is_object() {
return Err(ManifestRejection::SchemaViolation {
field: format!("extensions.{ns}"),
expected: "an object",
});
}
}
manifest.extensions = Extensions { entries: entries.clone() };
}
_ => diagnostics.push(Diagnostic::new(
Rule::ExtensionsIgnored,
Origin::Manifest,
"`extensions` is not an object; field ignored (§8.1)",
)),
},
unknown => diagnostics.push(Diagnostic::new(
Rule::UnknownManifestField,
Origin::Manifest,
format!("unknown top-level field `{unknown}` ignored (§5.2)"),
)),
}
}
Ok((manifest, diagnostics))
}
}
fn expect_string(field: &str, value: &Value) -> Result<String, ManifestRejection> {
match value {
Value::String(s) => Ok(s.clone()),
_ => Err(ManifestRejection::SchemaViolation {
field: field.to_owned(),
expected: "a string",
}),
}
}
fn parse_author(value: &Value) -> Result<Author, ManifestRejection> {
let Value::Object(fields) = value else {
return Err(ManifestRejection::SchemaViolation {
field: "author".to_owned(),
expected: "an object",
});
};
let mut author = Author::default();
for (field, value) in fields {
let slot = match field.as_str() {
"name" => &mut author.name,
"email" => &mut author.email,
"url" => &mut author.url,
other => {
return Err(ManifestRejection::SchemaViolation {
field: format!("author.{other}"),
expected: "absent (only `name`, `email`, `url` are permitted)",
})
}
};
*slot = Some(expect_string(&format!("author.{field}"), value)?);
}
Ok(author)
}
fn parse_keywords(value: &Value) -> Result<Vec<String>, ManifestRejection> {
let Value::Array(items) = value else {
return Err(ManifestRejection::SchemaViolation {
field: "keywords".to_owned(),
expected: "an array of strings",
});
};
items.iter().map(|item| expect_string("keywords[]", item)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(json: &str) -> Result<(Manifest, Vec<Diagnostic>), ManifestRejection> {
Manifest::parse(json.as_bytes())
}
#[test]
fn minimal_manifest() {
let (manifest, diags) = parse(
r#"{"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "minimal-plugin"}"#,
)
.unwrap();
assert_eq!(manifest.name, *"minimal-plugin");
assert_eq!(manifest.spec, SpecVersion::V1_0_0);
assert!(diags.is_empty());
}
#[test]
fn unknown_field_is_reported_and_ignored() {
let (manifest, diags) = parse(
r#"{"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "p", "hooks": {}}"#,
)
.unwrap();
assert_eq!(manifest.name, *"p");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].rule, Rule::UnknownManifestField);
}
#[test]
fn non_object_extensions_is_reported_and_ignored() {
let (manifest, diags) = parse(
r#"{"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "p", "extensions": 7}"#,
)
.unwrap();
assert!(manifest.extensions.is_empty());
assert_eq!(diags[0].rule, Rule::ExtensionsIgnored);
}
#[test]
fn non_object_extension_namespace_is_fatal() {
let err = parse(
r#"{"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "p", "extensions": {"com.example.client": 7}}"#,
)
.unwrap_err();
assert!(matches!(err, ManifestRejection::SchemaViolation { .. }));
}
#[test]
fn wrong_type_is_fatal() {
let err = parse(
r#"{"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "p", "version": 2}"#,
)
.unwrap_err();
assert!(matches!(err, ManifestRejection::SchemaViolation { .. }));
}
#[test]
fn unsupported_schema_is_its_own_rejection() {
let err = parse(
r#"{"$schema": "https://agent-plugins.org/schemas/9.0.0/plugin.schema.json",
"name": "p"}"#,
)
.unwrap_err();
assert!(matches!(err, ManifestRejection::UnsupportedSchema { .. }));
}
#[test]
fn extension_lookup() {
let (manifest, _) = parse(
r#"{"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "p",
"extensions": {"com.example.client": {"setting": true}}}"#,
)
.unwrap();
let ns = manifest.extensions.namespace("com.example.client").unwrap();
assert_eq!(ns.get("setting"), Some(&Value::Bool(true)));
assert!(manifest.extensions.namespace("org.other").is_none());
}
}