返回 CodeWhale
mod.rs
1 //! Catalog parsers, one per real published schema.
2 //!
3 //! Each parser consumes only fields its format documents. Unknown fields
4 //! produce visible warnings, never silent acceptance and never invented
5 //! fallbacks: a field the format does not define is not parsed as some
6 //! plausible equivalent. There is no network access at this layer.
7
8 pub mod claude;
9 pub mod codewhale;
10 pub mod codex;
11 pub mod kimi;
12
13 use serde_json::Value;
14
15 use crate::plugins::manifest::{PluginCompatibility, PluginInventory};
16
17 use super::types::{
18 MarketplaceCatalog, MarketplaceCatalogId, MarketplaceDiagnostic, MarketplaceFormat,
19 };
20
21 /// Catalog-side compatibility. Unlike an installed bundle's reviewed
22 /// inventory, catalog declarations do not state MCP transport, so any
23 /// declared `mcp_servers` count is treated as a supported-capable
24 /// declaration; the install-time review decides the real activation
25 /// policy binding.
26 pub(super) fn declared_catalog_compatibility(declared: &PluginInventory) -> PluginCompatibility {
27 let supported = declared.skills > 0 || declared.mcp_servers > 0;
28 let unsupported = declared.commands > 0
29 || declared.agents > 0
30 || declared.hooks > 0
31 || declared.lsp > 0
32 || declared.native > 0
33 || !declared.filesystem_roots.is_empty()
34 || !declared.network_hosts.is_empty()
35 || declared.lifecycle_mutation;
36 match (supported, unsupported) {
37 (true, false) => PluginCompatibility::Full,
38 (true, true) => PluginCompatibility::Partial,
39 (false, true) => PluginCompatibility::Unsupported,
40 (false, false) => PluginCompatibility::Full,
41 }
42 }
43
44 /// Input to catalog parsing. `base` is where the document was read from
45 /// (path or URL) — kept for install-time resolution of relative sources;
46 /// the parser itself never touches it.
47 pub struct MarketplaceDocument {
48 pub catalog_id: MarketplaceCatalogId,
49 pub format: MarketplaceFormat,
50 pub root: Value,
51 pub base: Option<String>,
52 }
53
54 /// Parse one catalog document. `MarketplaceFormat::Auto` detects the
55 /// format from documented structural markers only, and reports ambiguity
56 /// as an error diagnostic rather than guessing.
57 pub fn parse_catalog(document: MarketplaceDocument) -> MarketplaceCatalog {
58 let format = match document.format {
59 MarketplaceFormat::Auto => match detect_format(&document.root) {
60 Ok(format) => format,
61 Err(diagnostic) => {
62 let name = document.catalog_id.as_str().to_string();
63 return MarketplaceCatalog {
64 id: document.catalog_id,
65 format: MarketplaceFormat::Auto,
66 name,
67 display_name: None,
68 description: None,
69 version: None,
70 base: document.base,
71 provenance: super::types::CatalogProvenance::default(),
72 candidates: Vec::new(),
73 diagnostics: vec![diagnostic],
74 };
75 }
76 },
77 explicit => explicit,
78 };
79
80 let mut catalog = match format {
81 MarketplaceFormat::Kimi => kimi::parse_kimi_catalog(document),
82 MarketplaceFormat::Claude => claude::parse_claude_catalog(document),
83 MarketplaceFormat::Codex => codex::parse_codex_catalog(document),
84 MarketplaceFormat::Codewhale => codewhale::parse_codewhale_catalog(document),
85 MarketplaceFormat::Auto => unreachable!("resolved above"),
86 };
87 flag_duplicate_names(&mut catalog);
88 catalog
89 }
90
91 /// A candidate's name is its identity: `/plugin show`, install, and registry
92 /// lookups all resolve by it, and a lookup returns the first match. Entries
93 /// sharing a name (after each format's normalization) are ambiguous, so every
94 /// one of them carries an error and none installs from the catalog.
95 fn flag_duplicate_names(catalog: &mut super::types::MarketplaceCatalog) {
96 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
97 for candidate in &catalog.candidates {
98 *counts.entry(candidate.name.clone()).or_default() += 1;
99 }
100 for candidate in &mut catalog.candidates {
101 let count = counts.get(&candidate.name).copied().unwrap_or(0);
102 if count > 1 {
103 candidate.diagnostics.push(MarketplaceDiagnostic::error(
104 "DUPLICATE_NAME",
105 format!(
106 "{count} catalog entries are named `{}`; the entry's identity is ambiguous",
107 candidate.name
108 ),
109 Some(candidate.name.clone()),
110 None,
111 ));
112 }
113 }
114 }
115
116 /// Detection uses only markers each format's own documentation defines:
117 ///
118 /// - **Kimi**: `plugins[]` whose entries carry `id` + `source` (Kimi uses
119 /// `id`; the Claude-family formats use `name`).
120 /// - **Claude**: top-level `owner` object, a `plugins[]` entry with a
121 /// `source` object using the Claude discriminators (`github`, `url`,
122 /// `git-subdir`, `npm`, `archive`, `command`), or `metadata.pluginRoot`.
123 /// - **Codex**: a `plugins[]` entry with a `policy` object, a `source`
124 /// object with the `local` discriminator, or a top-level `interface`
125 /// object.
126 /// - **Codewhale**: `plugins[]` entries with `name` + a string `source`
127 /// that is a Codewhale install spec (`github:`, `path:`, URL).
128 ///
129 /// Documents matching no documented marker are ambiguous, not guessed.
130 fn detect_format(root: &Value) -> Result<MarketplaceFormat, MarketplaceDiagnostic> {
131 let Some(obj) = root.as_object() else {
132 return Err(MarketplaceDiagnostic::error(
133 "NOT_AN_OBJECT",
134 "marketplace catalog must be a JSON object",
135 None,
136 None,
137 ));
138 };
139 let Some(entries) = obj.get("plugins").and_then(Value::as_array) else {
140 return Err(MarketplaceDiagnostic::error(
141 "UNKNOWN_FORMAT",
142 "catalog has no documented marker: expected Kimi `plugins` with `id` entries, \
143 Claude `owner`/`plugins`, Codex `policy`/`interface`, or Codewhale `plugins` \
144 with install-spec sources",
145 None,
146 None,
147 ));
148 };
149
150 // Claude: `owner` and `metadata.pluginRoot` are documented top-level
151 // fields no other format defines.
152 if obj.contains_key("owner")
153 || obj
154 .get("metadata")
155 .and_then(|m| m.get("pluginRoot"))
156 .is_some()
157 {
158 return Ok(MarketplaceFormat::Claude);
159 }
160
161 let entry_markers: Vec<MapMarker> = entries
162 .iter()
163 .map(|entry| {
164 let entry_obj = entry.as_object();
165 MapMarker {
166 has_id: entry_obj.is_some_and(|o| o.contains_key("id")),
167 has_name: entry_obj.is_some_and(|o| o.contains_key("name")),
168 source_kind: entry_obj.and_then(|o| o.get("source")).map(source_marker),
169 has_policy: entry_obj.is_some_and(|o| o.contains_key("policy")),
170 }
171 })
172 .collect();
173
174 // Codex: `policy` blocks or `local` sources are Codex-only markers.
175 if entry_markers
176 .iter()
177 .any(|m| m.has_policy || m.source_kind == Some(SourceMarker::Local))
178 || obj.contains_key("interface")
179 {
180 return Ok(MarketplaceFormat::Codex);
181 }
182
183 // Claude source discriminators other than `local`.
184 if entry_markers.iter().any(|m| {
185 matches!(
186 m.source_kind,
187 Some(SourceMarker::Github)
188 | Some(SourceMarker::Url)
189 | Some(SourceMarker::GitSubdir)
190 | Some(SourceMarker::Npm)
191 | Some(SourceMarker::Archive)
192 | Some(SourceMarker::Command)
193 )
194 }) {
195 return Ok(MarketplaceFormat::Claude);
196 }
197
198 // Kimi: `id`-keyed entries with string sources.
199 if entry_markers.iter().any(|m| {
200 m.has_id
201 && matches!(
202 m.source_kind,
203 Some(SourceMarker::String | SourceMarker::UrlString)
204 )
205 }) {
206 return Ok(MarketplaceFormat::Kimi);
207 }
208
209 // Codewhale: `name` entries with install-spec string sources.
210 if entry_markers.iter().any(|m| {
211 m.has_name
212 && matches!(
213 m.source_kind,
214 Some(SourceMarker::InstallSpec | SourceMarker::UrlString)
215 )
216 }) {
217 return Ok(MarketplaceFormat::Codewhale);
218 }
219
220 Err(MarketplaceDiagnostic::error(
221 "AMBIGUOUS_FORMAT",
222 "catalog markers match no documented format uniquely; pass the format explicitly",
223 None,
224 None,
225 ))
226 }
227
228 struct MapMarker {
229 has_id: bool,
230 has_name: bool,
231 source_kind: Option<SourceMarker>,
232 has_policy: bool,
233 }
234
235 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
236 enum SourceMarker {
237 String,
238 UrlString,
239 InstallSpec,
240 Github,
241 Url,
242 GitSubdir,
243 Npm,
244 Archive,
245 Command,
246 Local,
247 OtherObject,
248 }
249
250 fn source_marker(source: &Value) -> SourceMarker {
251 match source {
252 Value::String(s) => {
253 if s.starts_with("github:") || s.starts_with("path:") {
254 SourceMarker::InstallSpec
255 } else if s.starts_with("https://") || s.starts_with("http://") {
256 SourceMarker::UrlString
257 } else {
258 SourceMarker::String
259 }
260 }
261 Value::Object(o) => match o.get("source").and_then(Value::as_str) {
262 Some("github") => SourceMarker::Github,
263 Some("url") => SourceMarker::Url,
264 Some("git-subdir") => SourceMarker::GitSubdir,
265 Some("npm") => SourceMarker::Npm,
266 Some("archive") => SourceMarker::Archive,
267 Some("command") => SourceMarker::Command,
268 Some("local") => SourceMarker::Local,
269 _ => SourceMarker::OtherObject,
270 },
271 _ => SourceMarker::OtherObject,
272 }
273 }
274
275 /// Shared helper: read a documented string field; a wrong-typed value is
276 /// a per-entry warning and treated as absent, not a guess.
277 pub(super) fn str_field<'a>(
278 entry: &'a serde_json::Map<String, Value>,
279 field: &str,
280 ) -> (Option<&'a str>, Option<MarketplaceDiagnostic>) {
281 match entry.get(field) {
282 None | Some(Value::Null) => (None, None),
283 Some(Value::String(s)) => (Some(s.as_str()), None),
284 Some(other) => (
285 None,
286 Some(MarketplaceDiagnostic::warning(
287 "FIELD_TYPE",
288 format!("field `{field}` must be a string, got {}", type_name(other)),
289 None,
290 None,
291 )),
292 ),
293 }
294 }
295
296 /// Shared helper: read a documented string-array field.
297 pub(super) fn str_array_field(
298 entry: &serde_json::Map<String, Value>,
299 field: &str,
300 ) -> (Vec<String>, Option<MarketplaceDiagnostic>) {
301 match entry.get(field) {
302 None | Some(Value::Null) => (Vec::new(), None),
303 Some(Value::Array(items)) => {
304 let mut out = Vec::new();
305 let mut skipped = false;
306 for item in items {
307 match item {
308 Value::String(s) => out.push(s.clone()),
309 _ => skipped = true,
310 }
311 }
312 let diag = skipped.then(|| {
313 MarketplaceDiagnostic::warning(
314 "FIELD_TYPE",
315 format!(
316 "field `{field}` must be an array of strings; non-string items skipped"
317 ),
318 None,
319 None,
320 )
321 });
322 (out, diag)
323 }
324 Some(other) => (
325 Vec::new(),
326 Some(MarketplaceDiagnostic::warning(
327 "FIELD_TYPE",
328 format!(
329 "field `{field}` must be an array of strings, got {}",
330 type_name(other)
331 ),
332 None,
333 None,
334 )),
335 ),
336 }
337 }
338
339 /// Shared helper: warn on fields this format does not document. Unknown
340 /// fields are preserved in diagnostics so catalog authors see them; they
341 /// are never silently reinterpreted.
342 pub(super) fn unknown_fields_warning(
343 entry: &serde_json::Map<String, Value>,
344 known: &[&str],
345 ) -> Option<MarketplaceDiagnostic> {
346 let unknown: Vec<&String> = entry
347 .keys()
348 .filter(|k| !known.contains(&k.as_str()))
349 .collect();
350 if unknown.is_empty() {
351 return None;
352 }
353 Some(MarketplaceDiagnostic::warning(
354 "UNKNOWN_FIELD",
355 format!(
356 "undeclared field(s) ignored: {}",
357 unknown
358 .iter()
359 .map(|s| s.as_str())
360 .collect::<Vec<_>>()
361 .join(", ")
362 ),
363 None,
364 None,
365 ))
366 }
367
368 fn type_name(value: &Value) -> &'static str {
369 match value {
370 Value::Null => "null",
371 Value::Bool(_) => "a boolean",
372 Value::Number(_) => "a number",
373 Value::String(_) => "a string",
374 Value::Array(_) => "an array",
375 Value::Object(_) => "an object",
376 }
377 }
378
378 lines RUST