1use std::error::Error;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
8#[serde(rename_all = "camelCase")]
9pub enum MarketplaceKind {
10 Claude,
11 Cursor,
12 Codex,
13 Roder,
14 Custom,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "camelCase")]
19pub enum DefaultMarketplaceSelection {
20 None,
21 Anthropic,
22 Cursor,
23 Codex,
24 All,
25}
26
27impl DefaultMarketplaceSelection {
28 pub fn selected_ids(&self) -> &'static [&'static str] {
29 match self {
30 Self::None => &[],
31 Self::Anthropic => &["claude-plugins-official"],
32 Self::Cursor => &["cursor-plugins"],
33 Self::Codex => &["codex-plugins"],
34 Self::All => &["claude-plugins-official", "cursor-plugins", "codex-plugins"],
35 }
36 }
37}
38
39impl std::str::FromStr for DefaultMarketplaceSelection {
40 type Err = MarketplaceError;
41
42 fn from_str(value: &str) -> Result<Self, Self::Err> {
43 match value.trim().to_ascii_lowercase().as_str() {
44 "none" => Ok(Self::None),
45 "anthropic" | "claude" => Ok(Self::Anthropic),
46 "cursor" => Ok(Self::Cursor),
47 "codex" => Ok(Self::Codex),
48 "all" => Ok(Self::All),
49 other => Err(MarketplaceError::InvalidDefaultSelection {
50 selection: other.to_string(),
51 }),
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(tag = "kind", rename_all = "camelCase")]
58pub enum MarketplaceSource {
59 Github {
60 repo: String,
61 #[serde(rename = "refName")]
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 ref_name: Option<String>,
64 #[serde(rename = "catalogPath")]
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 catalog_path: Option<String>,
67 #[serde(rename = "pluginRoot")]
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 plugin_root: Option<String>,
70 },
71 Git {
72 url: String,
73 #[serde(rename = "refName")]
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 ref_name: Option<String>,
76 #[serde(rename = "catalogPath")]
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 catalog_path: Option<String>,
79 },
80 HttpJson {
81 url: String,
82 },
83 LocalPath {
84 path: String,
85 },
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "camelCase")]
90pub enum MarketplaceState {
91 BakedIn,
92 Installed,
93 Refreshed,
94 Disabled,
95 RemovedByUser,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99#[serde(rename_all = "camelCase")]
100pub struct MarketplaceDescriptor {
101 pub id: String,
102 pub kind: MarketplaceKind,
103 pub display_name: String,
104 pub source: MarketplaceSource,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub homepage: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub owner_name: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub owner_email: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub description: Option<String>,
113 #[serde(default)]
114 pub is_default: bool,
115 #[serde(default = "default_enabled")]
116 pub enabled: bool,
117 #[serde(default = "default_marketplace_state")]
118 pub state: MarketplaceState,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 #[serde(with = "time::serde::rfc3339::option")]
121 pub last_refreshed_at: Option<OffsetDateTime>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub content_hash: Option<String>,
124}
125
126fn default_enabled() -> bool {
127 true
128}
129
130fn default_marketplace_state() -> MarketplaceState {
131 MarketplaceState::BakedIn
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135#[serde(tag = "kind", rename_all = "camelCase")]
136pub enum PluginSource {
137 MarketplacePath {
138 marketplace_id: String,
139 path: String,
140 },
141 Git {
142 url: String,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 path: Option<String>,
145 #[serde(rename = "refName")]
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 ref_name: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 sha: Option<String>,
150 },
151 Http {
152 url: String,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 sha: Option<String>,
155 },
156 Npm {
157 package: String,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 version: Option<String>,
160 },
161 LocalPath {
162 path: String,
163 },
164 Unsupported {
165 value: serde_json::Value,
166 },
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
170#[serde(rename_all = "camelCase")]
171pub struct PluginComponentHints {
172 #[serde(default)]
173 pub skills: bool,
174 #[serde(default)]
175 pub commands: bool,
176 #[serde(default)]
177 pub agents: bool,
178 #[serde(default)]
179 pub mcp_servers: bool,
180 #[serde(default)]
181 pub hooks: bool,
182 #[serde(default)]
183 pub apps: bool,
184 #[serde(default)]
185 pub lsp_servers: bool,
186 #[serde(default)]
187 pub rules: bool,
188 #[serde(default)]
189 pub assets: bool,
190}
191
192impl PluginComponentHints {
193 pub fn command_capable(&self) -> bool {
194 self.mcp_servers || self.hooks || self.apps || self.lsp_servers
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
199#[serde(rename_all = "camelCase")]
200pub enum MarketplacePluginRisk {
201 Passive,
202 ReadsWorkspace,
203 StartsProcess,
204 RunsHook,
205 Unknown,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
209#[serde(rename_all = "camelCase")]
210pub struct PluginIdentityKey {
211 pub canonical_slug: String,
212 pub normalized_name: String,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub repository: Option<String>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub homepage_domain: Option<String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub author_name: Option<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
222#[serde(rename_all = "camelCase")]
223pub struct MarketplacePluginEntry {
224 pub marketplace_id: String,
225 pub plugin_id: String,
226 pub identity_key: PluginIdentityKey,
227 pub display_name: String,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub description: Option<String>,
230 pub kind: MarketplaceKind,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub version: Option<String>,
233 pub source: PluginSource,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub homepage: Option<String>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub repository: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub author_name: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub category: Option<String>,
242 #[serde(default)]
243 pub tags: Vec<String>,
244 #[serde(default)]
245 pub component_hints: PluginComponentHints,
246 #[serde(default)]
247 pub capability_hints: Vec<String>,
248 pub risk: MarketplacePluginRisk,
249 #[serde(default)]
250 pub raw_manifest: serde_json::Value,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254#[serde(rename_all = "camelCase")]
255pub struct MarketplacePluginVariant {
256 pub marketplace_id: String,
257 pub plugin_id: String,
258 pub kind: MarketplaceKind,
259 pub source: PluginSource,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub homepage: Option<String>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub category: Option<String>,
264 #[serde(default)]
265 pub tags: Vec<String>,
266 #[serde(default)]
267 pub component_hints: PluginComponentHints,
268 #[serde(default)]
269 pub capability_hints: Vec<String>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub version: Option<String>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub content_hash: Option<String>,
274 pub risk: MarketplacePluginRisk,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
278#[serde(rename_all = "camelCase")]
279pub struct DedupedMarketplacePlugin {
280 pub identity_key: PluginIdentityKey,
281 pub display_name: String,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub description: Option<String>,
284 pub variants: Vec<MarketplacePluginVariant>,
285 #[serde(default)]
286 pub related_candidates: Vec<MarketplacePluginVariant>,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub recommended_variant_key: Option<String>,
289 #[serde(default)]
290 pub installed_variants: Vec<String>,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294#[serde(rename_all = "camelCase")]
295pub enum MarketplaceInstallState {
296 Previewed,
297 Installed,
298 Disabled,
299 Uninstalled,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
303#[serde(rename_all = "camelCase")]
304pub struct InstalledPluginRecord {
305 pub marketplace_id: String,
306 pub plugin_id: String,
307 pub identity_key: PluginIdentityKey,
308 pub variant_key: String,
309 pub install_path: String,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub version: Option<String>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub content_hash: Option<String>,
314 pub state: MarketplaceInstallState,
315 #[serde(with = "time::serde::rfc3339")]
316 pub installed_at: OffsetDateTime,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
320#[serde(tag = "kind", rename_all = "camelCase")]
321pub enum MarketplaceError {
322 InvalidMarketplaceId {
323 id: String,
324 },
325 InvalidPluginId {
326 id: String,
327 },
328 InvalidIdentityKey {
329 key: String,
330 },
331 DuplicateMarketplace {
332 id: String,
333 },
334 DuplicatePlugin {
335 marketplace_id: String,
336 plugin_id: String,
337 },
338 InvalidSource {
339 message: String,
340 },
341 UnsupportedSource {
342 message: String,
343 },
344 InvalidDefaultSelection {
345 selection: String,
346 },
347 Io {
348 message: String,
349 },
350 Parse {
351 message: String,
352 },
353 NotFound {
354 message: String,
355 },
356}
357
358impl fmt::Display for MarketplaceError {
359 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360 match self {
361 Self::InvalidMarketplaceId { id } => write!(f, "invalid marketplace id `{id}`"),
362 Self::InvalidPluginId { id } => write!(f, "invalid plugin id `{id}`"),
363 Self::InvalidIdentityKey { key } => write!(f, "invalid plugin identity key `{key}`"),
364 Self::DuplicateMarketplace { id } => write!(f, "duplicate marketplace `{id}`"),
365 Self::DuplicatePlugin {
366 marketplace_id,
367 plugin_id,
368 } => write!(
369 f,
370 "duplicate plugin `{plugin_id}` in marketplace `{marketplace_id}`"
371 ),
372 Self::InvalidSource { message } => write!(f, "invalid marketplace source: {message}"),
373 Self::UnsupportedSource { message } => {
374 write!(f, "unsupported marketplace source: {message}")
375 }
376 Self::InvalidDefaultSelection { selection } => {
377 write!(f, "invalid default marketplace selection `{selection}`")
378 }
379 Self::Io { message } => write!(f, "marketplace io error: {message}"),
380 Self::Parse { message } => write!(f, "marketplace parse error: {message}"),
381 Self::NotFound { message } => write!(f, "marketplace entry not found: {message}"),
382 }
383 }
384}
385
386impl Error for MarketplaceError {}
387
388pub fn validate_marketplace_id(id: &str) -> Result<(), MarketplaceError> {
389 validate_slug(id).map_err(|_| MarketplaceError::InvalidMarketplaceId { id: id.to_string() })
390}
391
392pub fn validate_plugin_id(id: &str) -> Result<(), MarketplaceError> {
393 validate_slug(id).map_err(|_| MarketplaceError::InvalidPluginId { id: id.to_string() })
394}
395
396pub fn validate_identity_key(identity: &PluginIdentityKey) -> Result<(), MarketplaceError> {
397 if identity.canonical_slug.trim().is_empty()
398 || identity.normalized_name.trim().is_empty()
399 || normalize_slug(&identity.canonical_slug) != identity.canonical_slug
400 || normalize_slug(&identity.normalized_name).is_empty()
401 {
402 return Err(MarketplaceError::InvalidIdentityKey {
403 key: identity.canonical_slug.clone(),
404 });
405 }
406 for value in [
407 identity.repository.as_deref(),
408 identity.homepage_domain.as_deref(),
409 identity.author_name.as_deref(),
410 ]
411 .into_iter()
412 .flatten()
413 {
414 if value.trim().is_empty() {
415 return Err(MarketplaceError::InvalidIdentityKey {
416 key: identity.canonical_slug.clone(),
417 });
418 }
419 }
420 Ok(())
421}
422
423pub fn validate_marketplace_source(source: &MarketplaceSource) -> Result<(), MarketplaceError> {
424 match source {
425 MarketplaceSource::Github {
426 repo,
427 ref_name,
428 catalog_path,
429 plugin_root,
430 } => {
431 if repo.trim().is_empty()
432 || repo.starts_with('/')
433 || repo.contains("..")
434 || repo.split('/').count() != 2
435 {
436 return invalid_source("github repo must be owner/repo");
437 }
438 validate_optional_path(catalog_path.as_deref(), "catalogPath")?;
439 validate_optional_path(plugin_root.as_deref(), "pluginRoot")?;
440 validate_optional_ref(ref_name.as_deref())?;
441 }
442 MarketplaceSource::Git {
443 url,
444 ref_name,
445 catalog_path,
446 } => {
447 validate_url(url, &["https://", "ssh://", "git@", "file://"])?;
448 validate_optional_path(catalog_path.as_deref(), "catalogPath")?;
449 validate_optional_ref(ref_name.as_deref())?;
450 }
451 MarketplaceSource::HttpJson { url } => {
452 validate_url(url, &["https://", "http://", "file://"])?;
453 }
454 MarketplaceSource::LocalPath { path } => validate_path_text(path, "local path")?,
455 }
456 Ok(())
457}
458
459pub fn validate_plugin_source(source: &PluginSource) -> Result<(), MarketplaceError> {
460 match source {
461 PluginSource::MarketplacePath {
462 marketplace_id,
463 path,
464 } => {
465 validate_marketplace_id(marketplace_id)?;
466 validate_path_text(path, "marketplace path")?;
467 }
468 PluginSource::Git {
469 url,
470 path,
471 ref_name,
472 sha,
473 } => {
474 validate_url(url, &["https://", "ssh://", "git@", "file://"])?;
475 validate_optional_path(path.as_deref(), "path")?;
476 validate_optional_ref(ref_name.as_deref())?;
477 validate_optional_ref(sha.as_deref())?;
478 }
479 PluginSource::Http { url, sha } => {
480 validate_url(url, &["https://", "http://", "file://"])?;
481 validate_optional_ref(sha.as_deref())?;
482 }
483 PluginSource::Npm { package, version } => {
484 if package.trim().is_empty() || package.contains(char::is_whitespace) {
485 return invalid_source("npm package must be non-empty and whitespace-free");
486 }
487 validate_optional_ref(version.as_deref())?;
488 }
489 PluginSource::LocalPath { path } => validate_path_text(path, "local path")?,
490 PluginSource::Unsupported { value } => {
491 return Err(MarketplaceError::UnsupportedSource {
492 message: value.to_string(),
493 });
494 }
495 }
496 Ok(())
497}
498
499pub fn validate_plugin_entry(entry: &MarketplacePluginEntry) -> Result<(), MarketplaceError> {
500 validate_marketplace_id(&entry.marketplace_id)?;
501 validate_plugin_id(&entry.plugin_id)?;
502 validate_identity_key(&entry.identity_key)?;
503 validate_plugin_source(&entry.source)?;
504 if entry.display_name.trim().is_empty() {
505 return Err(MarketplaceError::InvalidPluginId {
506 id: entry.plugin_id.clone(),
507 });
508 }
509 Ok(())
510}
511
512fn validate_slug(value: &str) -> Result<(), ()> {
513 let mut chars = value.chars();
514 let Some(first) = chars.next() else {
515 return Err(());
516 };
517 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
518 return Err(());
519 }
520 let mut last = first;
521 for ch in chars {
522 if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '.') {
523 return Err(());
524 }
525 last = ch;
526 }
527 if !(last.is_ascii_lowercase() || last.is_ascii_digit()) {
528 return Err(());
529 }
530 Ok(())
531}
532
533fn validate_url(value: &str, allowed_prefixes: &[&str]) -> Result<(), MarketplaceError> {
534 let value = value.trim();
535 if value.is_empty() {
536 return invalid_source("url must be non-empty");
537 }
538 if allowed_prefixes
539 .iter()
540 .any(|prefix| value.starts_with(prefix))
541 {
542 Ok(())
543 } else {
544 invalid_source(format!("url has unsupported scheme: {value}"))
545 }
546}
547
548fn validate_optional_path(value: Option<&str>, label: &str) -> Result<(), MarketplaceError> {
549 if let Some(value) = value {
550 validate_relative_path_text(value, label)?;
551 }
552 Ok(())
553}
554
555fn validate_path_text(value: &str, label: &str) -> Result<(), MarketplaceError> {
556 if value.trim().is_empty() {
557 return invalid_source(format!("{label} must be non-empty"));
558 }
559 if value.split('/').any(|part| part == "..") {
560 return invalid_source(format!("{label} must not contain '..'"));
561 }
562 Ok(())
563}
564
565fn validate_relative_path_text(value: &str, label: &str) -> Result<(), MarketplaceError> {
566 validate_path_text(value, label)?;
567 if value.starts_with('/') {
568 return invalid_source(format!("{label} must be relative"));
569 }
570 Ok(())
571}
572
573fn validate_optional_ref(value: Option<&str>) -> Result<(), MarketplaceError> {
574 if let Some(value) = value
575 && (value.trim().is_empty() || value.contains(char::is_whitespace))
576 {
577 return invalid_source("ref, version, and sha values must be whitespace-free");
578 }
579 Ok(())
580}
581
582fn invalid_source(message: impl Into<String>) -> Result<(), MarketplaceError> {
583 Err(MarketplaceError::InvalidSource {
584 message: message.into(),
585 })
586}
587
588pub fn normalize_slug(value: &str) -> String {
589 let mut out = String::new();
590 let mut previous_dash = false;
591 for ch in value.chars().flat_map(|ch| ch.to_lowercase()) {
592 if ch.is_ascii_alphanumeric() {
593 out.push(ch);
594 previous_dash = false;
595 } else if !previous_dash {
596 out.push('-');
597 previous_dash = true;
598 }
599 }
600 out.trim_matches('-').to_string()
601}
602
603pub fn variant_key(marketplace_id: &str, plugin_id: &str) -> String {
604 format!("{marketplace_id}:{plugin_id}")
605}
606
607#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
608#[serde(rename_all = "camelCase")]
609pub struct MarketplaceUpdated {
610 pub marketplace: MarketplaceDescriptor,
611 #[serde(with = "time::serde::rfc3339")]
612 pub timestamp: OffsetDateTime,
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
616#[serde(rename_all = "camelCase")]
617pub struct MarketplacePluginInstalled {
618 pub plugin: InstalledPluginRecord,
619 #[serde(with = "time::serde::rfc3339")]
620 pub timestamp: OffsetDateTime,
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 #[test]
628 fn marketplace_sources_round_trip_camel_case() {
629 let source = MarketplaceSource::Github {
630 repo: "openai/plugins".to_string(),
631 ref_name: Some("main".to_string()),
632 catalog_path: None,
633 plugin_root: Some("plugins".to_string()),
634 };
635 let value = serde_json::to_value(&source).unwrap();
636 assert_eq!(value["kind"], "github");
637 assert_eq!(value["pluginRoot"], "plugins");
638 let decoded: MarketplaceSource = serde_json::from_value(value).unwrap();
639 assert_eq!(decoded, source);
640 }
641
642 #[test]
643 fn plugin_sources_round_trip_supported_shapes() {
644 for source in [
645 PluginSource::Git {
646 url: "https://github.com/openai/plugins.git".to_string(),
647 path: Some("plugins/superpowers".to_string()),
648 ref_name: Some("main".to_string()),
649 sha: Some("abc".to_string()),
650 },
651 PluginSource::Npm {
652 package: "@scope/plugin".to_string(),
653 version: Some("1.0.0".to_string()),
654 },
655 PluginSource::MarketplacePath {
656 marketplace_id: "codex-plugins".to_string(),
657 path: "plugins/demo".to_string(),
658 },
659 PluginSource::Http {
660 url: "https://example.test/plugin.zip".to_string(),
661 sha: None,
662 },
663 ] {
664 let value = serde_json::to_value(&source).unwrap();
665 let decoded: PluginSource = serde_json::from_value(value).unwrap();
666 assert_eq!(decoded, source);
667 }
668 }
669
670 #[test]
671 fn default_marketplace_selection_parses_expected_values() {
672 assert_eq!(
673 "anthropic".parse::<DefaultMarketplaceSelection>().unwrap(),
674 DefaultMarketplaceSelection::Anthropic
675 );
676 assert_eq!(
677 "claude".parse::<DefaultMarketplaceSelection>().unwrap(),
678 DefaultMarketplaceSelection::Anthropic
679 );
680 assert_eq!(
681 "all".parse::<DefaultMarketplaceSelection>().unwrap(),
682 DefaultMarketplaceSelection::All
683 );
684 assert!("bogus".parse::<DefaultMarketplaceSelection>().is_err());
685 }
686
687 #[test]
688 fn marketplace_validation_rejects_unsafe_sources_and_ids() {
689 assert!(validate_marketplace_id("cursor-local").is_ok());
690 assert!(validate_marketplace_id("Cursor Local").is_err());
691 assert!(
692 validate_marketplace_source(&MarketplaceSource::Github {
693 repo: "owner/plugins".to_string(),
694 ref_name: Some("main".to_string()),
695 catalog_path: Some(".cursor-plugin/marketplace.json".to_string()),
696 plugin_root: None,
697 })
698 .is_ok()
699 );
700 assert!(
701 validate_marketplace_source(&MarketplaceSource::Github {
702 repo: "../plugins".to_string(),
703 ref_name: None,
704 catalog_path: None,
705 plugin_root: None,
706 })
707 .is_err()
708 );
709 assert!(
710 validate_marketplace_source(&MarketplaceSource::Git {
711 url: "ftp://example.test/plugins.git".to_string(),
712 ref_name: None,
713 catalog_path: None,
714 })
715 .is_err()
716 );
717 assert!(
718 validate_marketplace_source(&MarketplaceSource::Github {
719 repo: "owner/plugins".to_string(),
720 ref_name: None,
721 catalog_path: Some("../marketplace.json".to_string()),
722 plugin_root: None,
723 })
724 .is_err()
725 );
726 }
727
728 #[test]
729 fn plugin_validation_rejects_bad_identity_and_unsupported_source() {
730 let mut entry = MarketplacePluginEntry {
731 marketplace_id: "cursor-local".to_string(),
732 plugin_id: "repo-tools".to_string(),
733 identity_key: PluginIdentityKey {
734 canonical_slug: "repo-tools".to_string(),
735 normalized_name: "repo tools".to_string(),
736 repository: Some("https://github.com/example/repo-tools".to_string()),
737 homepage_domain: Some("github.com".to_string()),
738 author_name: None,
739 },
740 display_name: "Repo Tools".to_string(),
741 description: None,
742 kind: MarketplaceKind::Cursor,
743 version: None,
744 source: PluginSource::MarketplacePath {
745 marketplace_id: "cursor-local".to_string(),
746 path: "repo-tools".to_string(),
747 },
748 homepage: None,
749 repository: None,
750 author_name: None,
751 category: None,
752 tags: Vec::new(),
753 component_hints: PluginComponentHints::default(),
754 capability_hints: Vec::new(),
755 risk: MarketplacePluginRisk::Passive,
756 raw_manifest: serde_json::json!({ "name": "repo-tools" }),
757 };
758 assert!(validate_plugin_entry(&entry).is_ok());
759
760 entry.identity_key.canonical_slug = "Repo Tools".to_string();
761 assert!(validate_plugin_entry(&entry).is_err());
762
763 entry.identity_key.canonical_slug = "repo-tools".to_string();
764 entry.source = PluginSource::Unsupported {
765 value: serde_json::json!({ "source": "unknown" }),
766 };
767 assert!(validate_plugin_entry(&entry).is_err());
768 }
769
770 #[test]
771 fn marketplace_contract_structs_use_camel_case_fields() {
772 let record = InstalledPluginRecord {
773 marketplace_id: "codex-plugins".to_string(),
774 plugin_id: "superpowers".to_string(),
775 identity_key: PluginIdentityKey {
776 canonical_slug: "superpowers".to_string(),
777 normalized_name: "superpowers".to_string(),
778 repository: Some("https://github.com/obra/superpowers".to_string()),
779 homepage_domain: Some("github.com".to_string()),
780 author_name: Some("Jesse Vincent".to_string()),
781 },
782 variant_key: variant_key("codex-plugins", "superpowers"),
783 install_path: "/tmp/cache/superpowers".to_string(),
784 version: Some("5.1.0".to_string()),
785 content_hash: Some("hash".to_string()),
786 state: MarketplaceInstallState::Installed,
787 installed_at: OffsetDateTime::UNIX_EPOCH,
788 };
789
790 let value = serde_json::to_value(record).unwrap();
791
792 assert_eq!(value["marketplaceId"], "codex-plugins");
793 assert_eq!(value["identityKey"]["canonicalSlug"], "superpowers");
794 assert_eq!(value["installedAt"], "1970-01-01T00:00:00Z");
795 }
796
797 #[test]
798 fn validates_slug_ids() {
799 assert!(validate_marketplace_id("codex-plugins").is_ok());
800 assert!(validate_plugin_id("superpowers.2").is_ok());
801 assert!(validate_marketplace_id("Bad").is_err());
802 assert!(validate_plugin_id("-bad").is_err());
803 }
804}