Skip to main content

roder_api/
marketplace.rs

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}