1use std::fmt;
14
15use serde_json::{Map, Value};
16
17use crate::diag::{Diagnostic, Origin, Rule};
18use crate::name::{InvalidName, PluginName};
19
20pub const PLUGIN_SCHEMA_1_0_0: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28#[non_exhaustive]
29pub enum SpecVersion {
30 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#[derive(Debug, Clone, PartialEq)]
42#[non_exhaustive]
43pub struct Manifest {
44 pub spec: SpecVersion,
46 pub name: PluginName,
48 pub version: Option<String>,
50 pub description: Option<String>,
52 pub author: Option<Author>,
54 pub homepage: Option<String>,
56 pub repository: Option<String>,
58 pub license: Option<String>,
60 pub keywords: Vec<String>,
62 pub extensions: Extensions,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Default)]
68#[non_exhaustive]
69pub struct Author {
70 pub name: Option<String>,
72 pub email: Option<String>,
74 pub url: Option<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Default)]
84pub struct Extensions {
85 entries: Map<String, Value>,
86}
87
88impl Extensions {
89 pub fn namespace(&self, ns: &str) -> Option<&Map<String, Value>> {
91 self.entries.get(ns).and_then(Value::as_object)
92 }
93
94 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 pub fn is_empty(&self) -> bool {
101 self.entries.is_empty()
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
108#[non_exhaustive]
109pub enum ManifestRejection {
110 NotJson {
112 detail: String,
114 },
115 NotAnObject,
117 MissingSchema,
119 UnsupportedSchema {
122 declared: String,
124 },
125 MissingName,
127 InvalidName(InvalidName),
129 SchemaViolation {
131 field: String,
133 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 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 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 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 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}