agent-plugins 0.1.1

Sans-IO implementation of the Agent Plugins specification (agent-plugins.org): load, validate, and prepare agent plugins from any IO
Documentation
//! The `plugin.json` manifest (§5) and its exact failure taxonomy.
//!
//! §5.2 draws a sharp line through validation failures:
//!
//! * an **unknown top-level field** and a **non-object `extensions`** are
//!   reported and ignored — the plugin still loads;
//! * *every other* schema violation is fatal — the plugin is rejected and no
//!   component may be discovered or executed.
//!
//! [`Manifest::parse`] returns that line as its type:
//! `Result<(Manifest, Vec<Diagnostic>), ManifestRejection>`.

use std::fmt;

use serde_json::{Map, Value};

use crate::diag::{Diagnostic, Origin, Rule};
use crate::name::{InvalidName, PluginName};

/// The canonical `$schema` identifier for Agent Plugins 1.0.0 manifests (§5.2).
pub const PLUGIN_SCHEMA_1_0_0: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";

/// The Agent Plugins specification versions this crate recognizes.
///
/// §5.2 requires a client to *select* validation rules from `$schema`, never
/// to fetch them. This enum is that selection: closed, local, offline.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SpecVersion {
    /// Agent Plugins 1.0.0.
    V1_0_0,
}

impl fmt::Display for SpecVersion {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("1.0.0")
    }
}

/// A validated plugin manifest.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Manifest {
    /// The Agent Plugins version declared by `$schema`.
    pub spec: SpecVersion,
    /// The validated plugin name (§5.5).
    pub name: PluginName,
    /// Version string; Semantic Versioning recommended, not enforced (§5.4).
    pub version: Option<String>,
    /// Short description of plugin purpose.
    pub description: Option<String>,
    /// Author metadata.
    pub author: Option<Author>,
    /// Documentation or homepage URL. Opaque per §5.4.
    pub homepage: Option<String>,
    /// Source repository URL. Opaque per §5.4.
    pub repository: Option<String>,
    /// License identifier; SPDX recommended, not enforced (§5.4).
    pub license: Option<String>,
    /// Search and discovery tags.
    pub keywords: Vec<String>,
    /// Client-specific manifest data, keyed by extension namespace (§8.1).
    pub extensions: Extensions,
}

/// Author metadata: a closed object of three optional strings (§5.4).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct Author {
    /// Author name.
    pub name: Option<String>,
    /// Author email address. Opaque per §5.4.
    pub email: Option<String>,
    /// Author URL. Opaque per §5.4.
    pub url: Option<String>,
}

/// Client extension data from the manifest `extensions` field (§8.1).
///
/// Namespaces this client does not implement are carried verbatim and never
/// validated — §8.1 forbids a client from judging another client's data.
/// Look up your own namespace with [`Extensions::namespace`].
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Extensions {
    entries: Map<String, Value>,
}

impl Extensions {
    /// The extension data object for a namespace, if the manifest carries one.
    pub fn namespace(&self, ns: &str) -> Option<&Map<String, Value>> {
        self.entries.get(ns).and_then(Value::as_object)
    }

    /// All declared namespaces and their data objects, in manifest order.
    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)))
    }

    /// Whether no extension data was declared.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

/// A fatal manifest problem: the plugin MUST be rejected and none of its
/// components discovered or executed (§5.2, §5.3).
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ManifestRejection {
    /// `plugin.json` is not valid JSON.
    NotJson {
        /// The JSON parser's explanation.
        detail: String,
    },
    /// The document is valid JSON but not an object.
    NotAnObject,
    /// The required `$schema` field is missing or not a string.
    MissingSchema,
    /// `$schema` declares an Agent Plugins version this client does not
    /// support (§5.2). The plugin may be valid for a future client.
    UnsupportedSchema {
        /// The `$schema` value the manifest declared.
        declared: String,
    },
    /// The required `name` field is missing or not a string.
    MissingName,
    /// The `name` field violates §5.5.
    InvalidName(InvalidName),
    /// A permitted field has the wrong shape — the fatal remainder of §5.2.
    SchemaViolation {
        /// The offending field, dotted for nested fields.
        field: String,
        /// What the specification requires there.
        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 {
    /// Parse and validate a `plugin.json` document (§5).
    ///
    /// On success, the accompanying diagnostics record any unknown top-level
    /// fields and a non-object `extensions` — reported and ignored per §5.2
    /// and §8.1. Every other schema violation rejects the manifest outright.
    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);
        };

        // §5.2: select validation rules from `$schema` before anything else.
        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) => {
                        // §5.2 closes the schema below the top level too: a
                        // non-object namespace value is a fatal violation.
                        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,
            // §5.4: the author object is closed; any other field is fatal.
            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());
    }
}