Skip to main content

agent_plugins/
manifest.rs

1//! The `plugin.json` manifest (§5) and its exact failure taxonomy.
2//!
3//! §5.2 draws a sharp line through validation failures:
4//!
5//! * an **unknown top-level field** and a **non-object `extensions`** are
6//!   reported and ignored — the plugin still loads;
7//! * *every other* schema violation is fatal — the plugin is rejected and no
8//!   component may be discovered or executed.
9//!
10//! [`Manifest::parse`] returns that line as its type:
11//! `Result<(Manifest, Vec<Diagnostic>), ManifestRejection>`.
12
13use std::fmt;
14
15use serde_json::{Map, Value};
16
17use crate::diag::{Diagnostic, Origin, Rule};
18use crate::name::{InvalidName, PluginName};
19
20/// The canonical `$schema` identifier for Agent Plugins 1.0.0 manifests (§5.2).
21pub const PLUGIN_SCHEMA_1_0_0: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
22
23/// The Agent Plugins specification versions this crate recognizes.
24///
25/// §5.2 requires a client to *select* validation rules from `$schema`, never
26/// to fetch them. This enum is that selection: closed, local, offline.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28#[non_exhaustive]
29pub enum SpecVersion {
30    /// Agent Plugins 1.0.0.
31    V1_0_0,
32}
33
34impl fmt::Display for SpecVersion {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.write_str("1.0.0")
37    }
38}
39
40/// A validated plugin manifest.
41#[derive(Debug, Clone, PartialEq)]
42#[non_exhaustive]
43pub struct Manifest {
44    /// The Agent Plugins version declared by `$schema`.
45    pub spec: SpecVersion,
46    /// The validated plugin name (§5.5).
47    pub name: PluginName,
48    /// Version string; Semantic Versioning recommended, not enforced (§5.4).
49    pub version: Option<String>,
50    /// Short description of plugin purpose.
51    pub description: Option<String>,
52    /// Author metadata.
53    pub author: Option<Author>,
54    /// Documentation or homepage URL. Opaque per §5.4.
55    pub homepage: Option<String>,
56    /// Source repository URL. Opaque per §5.4.
57    pub repository: Option<String>,
58    /// License identifier; SPDX recommended, not enforced (§5.4).
59    pub license: Option<String>,
60    /// Search and discovery tags.
61    pub keywords: Vec<String>,
62    /// Client-specific manifest data, keyed by extension namespace (§8.1).
63    pub extensions: Extensions,
64}
65
66/// Author metadata: a closed object of three optional strings (§5.4).
67#[derive(Debug, Clone, PartialEq, Eq, Default)]
68#[non_exhaustive]
69pub struct Author {
70    /// Author name.
71    pub name: Option<String>,
72    /// Author email address. Opaque per §5.4.
73    pub email: Option<String>,
74    /// Author URL. Opaque per §5.4.
75    pub url: Option<String>,
76}
77
78/// Client extension data from the manifest `extensions` field (§8.1).
79///
80/// Namespaces this client does not implement are carried verbatim and never
81/// validated — §8.1 forbids a client from judging another client's data.
82/// Look up your own namespace with [`Extensions::namespace`].
83#[derive(Debug, Clone, PartialEq, Default)]
84pub struct Extensions {
85    entries: Map<String, Value>,
86}
87
88impl Extensions {
89    /// The extension data object for a namespace, if the manifest carries one.
90    pub fn namespace(&self, ns: &str) -> Option<&Map<String, Value>> {
91        self.entries.get(ns).and_then(Value::as_object)
92    }
93
94    /// All declared namespaces and their data objects, in manifest order.
95    pub fn iter(&self) -> impl Iterator<Item = (&str, &Map<String, Value>)> {
96        self.entries.iter().filter_map(|(ns, v)| v.as_object().map(|obj| (ns.as_str(), obj)))
97    }
98
99    /// Whether no extension data was declared.
100    pub fn is_empty(&self) -> bool {
101        self.entries.is_empty()
102    }
103}
104
105/// A fatal manifest problem: the plugin MUST be rejected and none of its
106/// components discovered or executed (§5.2, §5.3).
107#[derive(Debug, Clone, PartialEq, Eq)]
108#[non_exhaustive]
109pub enum ManifestRejection {
110    /// `plugin.json` is not valid JSON.
111    NotJson {
112        /// The JSON parser's explanation.
113        detail: String,
114    },
115    /// The document is valid JSON but not an object.
116    NotAnObject,
117    /// The required `$schema` field is missing or not a string.
118    MissingSchema,
119    /// `$schema` declares an Agent Plugins version this client does not
120    /// support (§5.2). The plugin may be valid for a future client.
121    UnsupportedSchema {
122        /// The `$schema` value the manifest declared.
123        declared: String,
124    },
125    /// The required `name` field is missing or not a string.
126    MissingName,
127    /// The `name` field violates §5.5.
128    InvalidName(InvalidName),
129    /// A permitted field has the wrong shape — the fatal remainder of §5.2.
130    SchemaViolation {
131        /// The offending field, dotted for nested fields.
132        field: String,
133        /// What the specification requires there.
134        expected: &'static str,
135    },
136}
137
138impl fmt::Display for ManifestRejection {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            Self::NotJson { detail } => write!(f, "plugin.json is not valid JSON: {detail}"),
142            Self::NotAnObject => f.write_str("plugin.json must contain a top-level JSON object"),
143            Self::MissingSchema => {
144                f.write_str("required field `$schema` is missing or not a string (§5.3)")
145            }
146            Self::UnsupportedSchema { declared } => write!(
147                f,
148                "`$schema` declares an unsupported Agent Plugins version: {declared} (§5.2)"
149            ),
150            Self::MissingName => {
151                f.write_str("required field `name` is missing or not a string (§5.3)")
152            }
153            Self::InvalidName(err) => write!(f, "invalid plugin name (§5.5): {err}"),
154            Self::SchemaViolation { field, expected } => {
155                write!(f, "field `{field}` must be {expected} (§5.2)")
156            }
157        }
158    }
159}
160
161impl std::error::Error for ManifestRejection {}
162
163impl Manifest {
164    /// Parse and validate a `plugin.json` document (§5).
165    ///
166    /// On success, the accompanying diagnostics record any unknown top-level
167    /// fields and a non-object `extensions` — reported and ignored per §5.2
168    /// and §8.1. Every other schema violation rejects the manifest outright.
169    pub fn parse(bytes: &[u8]) -> Result<(Self, Vec<Diagnostic>), ManifestRejection> {
170        let document: Value = serde_json::from_slice(bytes)
171            .map_err(|e| ManifestRejection::NotJson { detail: e.to_string() })?;
172        let Value::Object(object) = document else {
173            return Err(ManifestRejection::NotAnObject);
174        };
175
176        // §5.2: select validation rules from `$schema` before anything else.
177        let spec = match object.get("$schema") {
178            Some(Value::String(id)) if id == PLUGIN_SCHEMA_1_0_0 => SpecVersion::V1_0_0,
179            Some(Value::String(other)) => {
180                return Err(ManifestRejection::UnsupportedSchema { declared: other.clone() })
181            }
182            _ => return Err(ManifestRejection::MissingSchema),
183        };
184
185        let name = match object.get("name") {
186            Some(Value::String(raw)) => {
187                PluginName::parse(raw).map_err(ManifestRejection::InvalidName)?
188            }
189            _ => return Err(ManifestRejection::MissingName),
190        };
191
192        let mut diagnostics = Vec::new();
193        let mut manifest = Manifest {
194            spec,
195            name,
196            version: None,
197            description: None,
198            author: None,
199            homepage: None,
200            repository: None,
201            license: None,
202            keywords: Vec::new(),
203            extensions: Extensions::default(),
204        };
205
206        for (field, value) in &object {
207            match field.as_str() {
208                "$schema" | "name" => {}
209                "version" => manifest.version = Some(expect_string(field, value)?),
210                "description" => manifest.description = Some(expect_string(field, value)?),
211                "homepage" => manifest.homepage = Some(expect_string(field, value)?),
212                "repository" => manifest.repository = Some(expect_string(field, value)?),
213                "license" => manifest.license = Some(expect_string(field, value)?),
214                "author" => manifest.author = Some(parse_author(value)?),
215                "keywords" => manifest.keywords = parse_keywords(value)?,
216                "extensions" => match value {
217                    Value::Object(entries) => {
218                        // §5.2 closes the schema below the top level too: a
219                        // non-object namespace value is a fatal violation.
220                        for (ns, data) in entries {
221                            if !data.is_object() {
222                                return Err(ManifestRejection::SchemaViolation {
223                                    field: format!("extensions.{ns}"),
224                                    expected: "an object",
225                                });
226                            }
227                        }
228                        manifest.extensions = Extensions { entries: entries.clone() };
229                    }
230                    _ => diagnostics.push(Diagnostic::new(
231                        Rule::ExtensionsIgnored,
232                        Origin::Manifest,
233                        "`extensions` is not an object; field ignored (§8.1)",
234                    )),
235                },
236                unknown => diagnostics.push(Diagnostic::new(
237                    Rule::UnknownManifestField,
238                    Origin::Manifest,
239                    format!("unknown top-level field `{unknown}` ignored (§5.2)"),
240                )),
241            }
242        }
243
244        Ok((manifest, diagnostics))
245    }
246}
247
248fn expect_string(field: &str, value: &Value) -> Result<String, ManifestRejection> {
249    match value {
250        Value::String(s) => Ok(s.clone()),
251        _ => Err(ManifestRejection::SchemaViolation {
252            field: field.to_owned(),
253            expected: "a string",
254        }),
255    }
256}
257
258fn parse_author(value: &Value) -> Result<Author, ManifestRejection> {
259    let Value::Object(fields) = value else {
260        return Err(ManifestRejection::SchemaViolation {
261            field: "author".to_owned(),
262            expected: "an object",
263        });
264    };
265    let mut author = Author::default();
266    for (field, value) in fields {
267        let slot = match field.as_str() {
268            "name" => &mut author.name,
269            "email" => &mut author.email,
270            "url" => &mut author.url,
271            // §5.4: the author object is closed; any other field is fatal.
272            other => {
273                return Err(ManifestRejection::SchemaViolation {
274                    field: format!("author.{other}"),
275                    expected: "absent (only `name`, `email`, `url` are permitted)",
276                })
277            }
278        };
279        *slot = Some(expect_string(&format!("author.{field}"), value)?);
280    }
281    Ok(author)
282}
283
284fn parse_keywords(value: &Value) -> Result<Vec<String>, ManifestRejection> {
285    let Value::Array(items) = value else {
286        return Err(ManifestRejection::SchemaViolation {
287            field: "keywords".to_owned(),
288            expected: "an array of strings",
289        });
290    };
291    items.iter().map(|item| expect_string("keywords[]", item)).collect()
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn parse(json: &str) -> Result<(Manifest, Vec<Diagnostic>), ManifestRejection> {
299        Manifest::parse(json.as_bytes())
300    }
301
302    #[test]
303    fn minimal_manifest() {
304        let (manifest, diags) = parse(
305            r#"{"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
306                "name": "minimal-plugin"}"#,
307        )
308        .unwrap();
309        assert_eq!(manifest.name, *"minimal-plugin");
310        assert_eq!(manifest.spec, SpecVersion::V1_0_0);
311        assert!(diags.is_empty());
312    }
313
314    #[test]
315    fn unknown_field_is_reported_and_ignored() {
316        let (manifest, diags) = parse(
317            r#"{"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
318                "name": "p", "hooks": {}}"#,
319        )
320        .unwrap();
321        assert_eq!(manifest.name, *"p");
322        assert_eq!(diags.len(), 1);
323        assert_eq!(diags[0].rule, Rule::UnknownManifestField);
324    }
325
326    #[test]
327    fn non_object_extensions_is_reported_and_ignored() {
328        let (manifest, diags) = parse(
329            r#"{"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
330                "name": "p", "extensions": 7}"#,
331        )
332        .unwrap();
333        assert!(manifest.extensions.is_empty());
334        assert_eq!(diags[0].rule, Rule::ExtensionsIgnored);
335    }
336
337    #[test]
338    fn non_object_extension_namespace_is_fatal() {
339        let err = parse(
340            r#"{"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
341                "name": "p", "extensions": {"com.example.client": 7}}"#,
342        )
343        .unwrap_err();
344        assert!(matches!(err, ManifestRejection::SchemaViolation { .. }));
345    }
346
347    #[test]
348    fn wrong_type_is_fatal() {
349        let err = parse(
350            r#"{"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
351                "name": "p", "version": 2}"#,
352        )
353        .unwrap_err();
354        assert!(matches!(err, ManifestRejection::SchemaViolation { .. }));
355    }
356
357    #[test]
358    fn unsupported_schema_is_its_own_rejection() {
359        let err = parse(
360            r#"{"$schema": "https://agent-plugins.org/schemas/9.0.0/plugin.schema.json",
361                "name": "p"}"#,
362        )
363        .unwrap_err();
364        assert!(matches!(err, ManifestRejection::UnsupportedSchema { .. }));
365    }
366
367    #[test]
368    fn extension_lookup() {
369        let (manifest, _) = parse(
370            r#"{"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
371                "name": "p",
372                "extensions": {"com.example.client": {"setting": true}}}"#,
373        )
374        .unwrap();
375        let ns = manifest.extensions.namespace("com.example.client").unwrap();
376        assert_eq!(ns.get("setting"), Some(&Value::Bool(true)));
377        assert!(manifest.extensions.namespace("org.other").is_none());
378    }
379}