agent_plugins/diag.rs
1//! Diagnostics: the spec's "SHOULD report" made concrete.
2//!
3//! Agent Plugins is deliberately forgiving — most failures narrow to the
4//! smallest boundary (§4.1, §11.3) and loading continues. What must not
5//! happen is *silence*. Every non-fatal decision the loader takes on the
6//! plugin's behalf is recorded as a [`Diagnostic`]: which [`Rule`] fired,
7//! where ([`Origin`]), and a human-readable why.
8
9use std::fmt;
10
11/// The closed set of non-fatal report conditions defined by the specification.
12///
13/// Each variant maps to the spec section that mandates the report, available
14/// via [`Rule::section`], and to a stable machine-readable [`Rule::code`].
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16#[non_exhaustive]
17pub enum Rule {
18 /// An unknown top-level manifest field was reported and ignored (§5.2).
19 UnknownManifestField,
20 /// A non-object `extensions` field was reported and ignored (§8.1).
21 ExtensionsIgnored,
22 /// A fixed component location exists but is unusable (§6.2, §4.1 rule 2).
23 ComponentInvalid,
24 /// A discovered skill was skipped (§7.1, §4.1 rule 3).
25 SkillSkipped,
26 /// An individual MCP server entry was skipped (§7.2.2 rules 3–4).
27 ServerSkipped,
28 /// MCP was disabled for the whole plugin (§7.2.2 rule 2).
29 McpDisabled,
30}
31
32impl Rule {
33 /// Stable kebab-case identifier, shared with the TypeScript implementation
34 /// and the cross-language conformance fixtures.
35 pub fn code(self) -> &'static str {
36 match self {
37 Self::UnknownManifestField => "unknown-manifest-field",
38 Self::ExtensionsIgnored => "extensions-ignored",
39 Self::ComponentInvalid => "component-invalid",
40 Self::SkillSkipped => "skill-skipped",
41 Self::ServerSkipped => "server-skipped",
42 Self::McpDisabled => "mcp-disabled",
43 }
44 }
45
46 /// The specification section that defines this report condition.
47 pub fn section(self) -> &'static str {
48 match self {
49 Self::UnknownManifestField => "§5.2",
50 Self::ExtensionsIgnored => "§8.1",
51 Self::ComponentInvalid => "§6.2",
52 Self::SkillSkipped => "§7.1",
53 Self::ServerSkipped => "§7.2.2",
54 Self::McpDisabled => "§7.2.2",
55 }
56 }
57}
58
59impl fmt::Display for Rule {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 write!(f, "{} ({})", self.code(), self.section())
62 }
63}
64
65/// Where in the package a diagnostic points.
66#[derive(Debug, Clone, PartialEq, Eq)]
67#[non_exhaustive]
68pub enum Origin {
69 /// The `plugin.json` manifest.
70 Manifest,
71 /// The `skills/` component location.
72 Skills,
73 /// One skill directory under `skills/`, by directory name.
74 Skill(String),
75 /// The `mcp.json` component location.
76 Mcp,
77 /// One MCP server entry, by its `mcpServers` member name.
78 Server(String),
79}
80
81impl fmt::Display for Origin {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 match self {
84 Self::Manifest => f.write_str("plugin.json"),
85 Self::Skills => f.write_str("skills/"),
86 Self::Skill(dir) => write!(f, "skills/{dir}/"),
87 Self::Mcp => f.write_str("mcp.json"),
88 Self::Server(name) => write!(f, "mcp.json#{name}"),
89 }
90 }
91}
92
93/// One reported, non-fatal loading decision.
94#[derive(Debug, Clone, PartialEq, Eq)]
95#[non_exhaustive]
96pub struct Diagnostic {
97 /// Which report condition fired.
98 pub rule: Rule,
99 /// Where in the package it fired.
100 pub origin: Origin,
101 /// Human-readable explanation.
102 pub message: String,
103}
104
105impl Diagnostic {
106 pub(crate) fn new(rule: Rule, origin: Origin, message: impl Into<String>) -> Self {
107 Self { rule, origin, message: message.into() }
108 }
109}
110
111impl fmt::Display for Diagnostic {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 write!(f, "{} at {}: {}", self.rule, self.origin, self.message)
114 }
115}