use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use serde::Deserialize;
use serde_json::Value;
use crate::error::{MindError, Result};
pub const PLUGIN_MANIFEST_SUBPATH: &str = ".claude-plugin/plugin.json";
pub const MARKETPLACE_MANIFEST_SUBPATH: &str = ".claude-plugin/marketplace.json";
pub fn plugin_manifest_path(root: &Path) -> PathBuf {
root.join(PLUGIN_MANIFEST_SUBPATH)
}
pub fn marketplace_manifest_path(root: &Path) -> PathBuf {
root.join(MARKETPLACE_MANIFEST_SUBPATH)
}
pub fn find_plugin_manifest(root: &Path) -> Option<PathBuf> {
let p = plugin_manifest_path(root);
p.is_file().then_some(p)
}
pub fn find_marketplace_manifest(root: &Path) -> Option<PathBuf> {
let p = marketplace_manifest_path(root);
p.is_file().then_some(p)
}
pub fn is_safe_manifest_path(rel: &str) -> bool {
if rel.is_empty() || rel.contains('\0') || rel.starts_with('~') {
return false;
}
let p = Path::new(rel);
if p.is_absolute() {
return false;
}
p.components()
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
}
#[derive(Debug, Deserialize)]
pub struct PluginManifest {
pub name: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
pub fn load_plugin_manifest(path: &Path) -> Result<PluginManifest> {
let text = std::fs::read_to_string(path).map_err(|e| MindError::io(path, e))?;
let manifest: PluginManifest =
serde_json::from_str(&text).map_err(|e| MindError::MindToml {
path: path.to_path_buf(),
msg: format!("invalid plugin.json: {e}"),
})?;
if manifest.name.trim().is_empty() {
return Err(MindError::MindToml {
path: path.to_path_buf(),
msg: "plugin.json: 'name' must be a non-empty string".to_string(),
});
}
Ok(manifest)
}
#[derive(Debug, Default)]
pub struct SkippedComponents {
pub commands: u32,
pub hooks: u32,
pub mcp_servers: u32,
pub lsp_servers: u32,
pub monitors: u32,
pub themes: u32,
pub output_styles: u32,
}
impl SkippedComponents {
pub fn total(&self) -> u32 {
self.commands
+ self.hooks
+ self.mcp_servers
+ self.lsp_servers
+ self.monitors
+ self.themes
+ self.output_styles
}
pub fn summary(&self) -> Option<String> {
if self.total() == 0 {
return None;
}
let mut parts: Vec<String> = Vec::new();
Self::push_part(&mut parts, self.commands, "command", "commands");
Self::push_part(&mut parts, self.hooks, "hook", "hooks");
Self::push_part(&mut parts, self.mcp_servers, "mcp server", "mcp servers");
Self::push_part(&mut parts, self.lsp_servers, "lsp server", "lsp servers");
Self::push_part(&mut parts, self.monitors, "monitor", "monitors");
Self::push_part(&mut parts, self.themes, "theme", "themes");
Self::push_part(
&mut parts,
self.output_styles,
"output style",
"output styles",
);
Some(format!(
"{} not installed (no mind equivalent)",
parts.join(", ")
))
}
fn push_part(parts: &mut Vec<String>, count: u32, singular: &str, plural: &str) {
if count > 0 {
parts.push(format!(
"{} {}",
count,
if count == 1 { singular } else { plural }
));
}
}
}
#[derive(Debug, PartialEq)]
pub enum PluginSource {
InRepo { path: String },
External { spec: String },
}
#[derive(Debug)]
pub struct MarketplaceEntry {
pub name: String,
pub source: PluginSource,
pub version: Option<String>,
pub description: Option<String>,
pub skills: Vec<String>,
}
#[derive(Debug)]
pub struct MarketplaceManifest {
#[allow(dead_code)]
pub name: String,
entries: Vec<MarketplaceEntry>,
}
impl MarketplaceManifest {
pub fn into_entries(self) -> Vec<MarketplaceEntry> {
self.entries
}
#[cfg(test)]
pub fn entries(&self) -> &[MarketplaceEntry] {
&self.entries
}
}
#[derive(Deserialize)]
struct RawMarketplace {
name: String,
#[serde(default)]
plugins: Vec<RawPluginEntry>,
}
#[derive(Deserialize)]
struct RawPluginEntry {
name: String,
source: RawSource,
#[serde(default)]
version: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
skills: Vec<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawSource {
Str(String),
Obj(HashMap<String, Value>),
}
pub fn load_marketplace_manifest(path: &Path) -> Result<MarketplaceManifest> {
let text = std::fs::read_to_string(path).map_err(|e| MindError::io(path, e))?;
let raw: RawMarketplace = serde_json::from_str(&text).map_err(|e| MindError::MindToml {
path: path.to_path_buf(),
msg: format!("invalid marketplace.json: {e}"),
})?;
let mut entries = Vec::with_capacity(raw.plugins.len());
for entry in raw.plugins {
if entry.name.trim().is_empty() {
return Err(MindError::MindToml {
path: path.to_path_buf(),
msg: "marketplace.json: entry 'name' must be a non-empty string".to_string(),
});
}
let source = resolve_source(entry.source, path)?;
for skill_path in &entry.skills {
if !is_safe_manifest_path(skill_path) {
return Err(MindError::MindToml {
path: path.to_path_buf(),
msg: format!(
"marketplace.json: skills path {:?} is unsafe (absolute, \
~-rooted, contains .., or contains NUL)",
skill_path
),
});
}
}
entries.push(MarketplaceEntry {
name: entry.name,
source,
version: entry.version,
description: entry.description,
skills: entry.skills,
});
}
Ok(MarketplaceManifest {
name: raw.name,
entries,
})
}
fn resolve_source(raw: RawSource, manifest_path: &Path) -> Result<PluginSource> {
match raw {
RawSource::Str(s) => resolve_string_source(s, manifest_path),
RawSource::Obj(obj) => {
let spec = extract_external_spec(&obj, manifest_path)?;
validate_object_pins(&obj)?;
crate::source::parse_spec(&spec)?;
Ok(PluginSource::External { spec })
}
}
}
fn resolve_string_source(s: String, manifest_path: &Path) -> Result<PluginSource> {
if is_external_string(&s) {
crate::source::parse_spec(&s)?;
return Ok(PluginSource::External { spec: s });
}
if !is_safe_manifest_path(&s) {
return Err(MindError::MindToml {
path: manifest_path.to_path_buf(),
msg: format!(
"marketplace.json: in-repo plugin path {:?} is unsafe (absolute, \
~-rooted, contains .., or contains NUL)",
s
),
});
}
Ok(PluginSource::InRepo { path: s })
}
fn is_external_string(s: &str) -> bool {
if s.contains("://") {
return true;
}
if s.starts_with("git@") {
return true;
}
if s.starts_with("github:") {
return true;
}
if s.starts_with('.') || s.starts_with('/') || s.starts_with('~') {
return false;
}
if let Some((owner, rest)) = s.split_once('/')
&& !owner.is_empty()
&& !rest.is_empty()
&& !rest.contains('/')
{
return true;
}
false
}
fn extract_external_spec(obj: &HashMap<String, Value>, manifest_path: &Path) -> Result<String> {
if let Some(Value::String(url)) = obj.get("url") {
return Ok(url.clone());
}
if let Some(Value::String(repo)) = obj.get("repo") {
return Ok(repo.clone());
}
Err(MindError::MindToml {
path: manifest_path.to_path_buf(),
msg: "marketplace.json: external source object must have a 'url' or 'repo' field"
.to_string(),
})
}
fn validate_object_pins(obj: &HashMap<String, Value>) -> Result<()> {
for key in &["ref", "pin-ref", "pin-tag", "follow-branch", "branch"] {
if let Some(Value::String(val)) = obj.get(*key) {
crate::git::validate_ref_value(val)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn write_temp(content: &str, label: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let path =
std::env::temp_dir().join(format!("mind-pm-{}-{}-{n}.json", std::process::id(), label));
std::fs::write(&path, content).expect("write temp file");
path
}
#[test]
fn plugin_manifest_path_appends_subpath() {
let root = Path::new("/my/repo");
assert_eq!(
plugin_manifest_path(root),
Path::new("/my/repo/.claude-plugin/plugin.json")
);
}
#[test]
fn marketplace_manifest_path_appends_subpath() {
let root = Path::new("/my/repo");
assert_eq!(
marketplace_manifest_path(root),
Path::new("/my/repo/.claude-plugin/marketplace.json")
);
}
#[test]
fn find_plugin_manifest_returns_none_when_absent() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("mind-pm-find-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
assert!(find_plugin_manifest(&dir).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn find_plugin_manifest_returns_some_when_present() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("mind-pm-find2-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let sub = dir.join(".claude-plugin");
std::fs::create_dir_all(&sub).expect("mkdir");
let manifest = sub.join("plugin.json");
std::fs::write(&manifest, r#"{"name":"x"}"#).expect("write");
assert_eq!(find_plugin_manifest(&dir), Some(manifest));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn find_marketplace_manifest_returns_none_when_absent() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("mind-pm-mfind-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
assert!(find_marketplace_manifest(&dir).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn find_marketplace_manifest_returns_some_when_present() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("mind-pm-mfind2-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let sub = dir.join(".claude-plugin");
std::fs::create_dir_all(&sub).expect("mkdir");
let manifest = sub.join("marketplace.json");
std::fs::write(&manifest, r#"{"name":"M","plugins":[]}"#).expect("write");
assert_eq!(find_marketplace_manifest(&dir), Some(manifest));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn safe_path_accepts_valid_paths() {
for path in &[
"foo",
"a/b/c.md",
"plugins/myplugin",
"deep/nested/path/file.txt",
"./relative",
] {
assert!(is_safe_manifest_path(path), "expected safe for {path:?}");
}
}
#[test]
fn safe_path_rejects_empty() {
assert!(!is_safe_manifest_path(""), "empty string must be rejected");
}
#[test]
fn safe_path_rejects_absolute() {
assert!(
!is_safe_manifest_path("/abs/path"),
"absolute path must be rejected"
);
assert!(!is_safe_manifest_path("/"), "root slash must be rejected");
}
#[test]
fn safe_path_rejects_tilde_root() {
assert!(
!is_safe_manifest_path("~/x"),
"tilde-rooted path must be rejected"
);
assert!(!is_safe_manifest_path("~"), "bare tilde must be rejected");
}
#[test]
fn safe_path_rejects_dotdot() {
assert!(!is_safe_manifest_path(".."), ".. must be rejected");
assert!(!is_safe_manifest_path("../up"), "../up must be rejected");
assert!(!is_safe_manifest_path("a/../b"), "a/../b must be rejected");
}
#[test]
fn safe_path_rejects_nul_byte() {
assert!(!is_safe_manifest_path("a\0b"), "NUL byte must be rejected");
}
#[test]
fn plugin_manifest_valid_full() {
let path = write_temp(
r#"{"name":"myplugin","version":"1.2.3","description":"A plugin"}"#,
"pm-full",
);
let m = load_plugin_manifest(&path).expect("should parse");
assert_eq!(m.name, "myplugin");
assert_eq!(m.version.as_deref(), Some("1.2.3"));
assert_eq!(m.description.as_deref(), Some("A plugin"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn plugin_manifest_valid_name_only() {
let path = write_temp(r#"{"name":"minimal"}"#, "pm-min");
let m = load_plugin_manifest(&path).expect("should parse");
assert_eq!(m.name, "minimal");
assert!(m.version.is_none());
assert!(m.description.is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn plugin_manifest_missing_name_is_mind_toml_error() {
let path = write_temp(r#"{"version":"1.0","description":"no name"}"#, "pm-noname");
let err = load_plugin_manifest(&path).unwrap_err();
match err {
MindError::MindToml { msg, .. } => {
assert!(
msg.contains("plugin.json") || msg.contains("name") || msg.contains("missing"),
"error must mention the problem: {msg}"
);
}
other => panic!("expected MindToml, got: {other:?}"),
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn plugin_manifest_empty_name_is_mind_toml_error() {
let path = write_temp(r#"{"name":""}"#, "pm-emptyname");
let err = load_plugin_manifest(&path).unwrap_err();
match err {
MindError::MindToml { msg, .. } => {
assert!(msg.contains("name"), "error must mention 'name': {msg}");
}
other => panic!("expected MindToml, got: {other:?}"),
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn plugin_manifest_whitespace_name_is_mind_toml_error() {
let path = write_temp(r#"{"name":" "}"#, "pm-wsname");
let err = load_plugin_manifest(&path).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"whitespace-only name must be MindToml error: {err:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn plugin_manifest_malformed_json_is_mind_toml_error() {
let path = write_temp(r#"{not valid json"#, "pm-badjson");
let err = load_plugin_manifest(&path).unwrap_err();
match err {
MindError::MindToml { msg, .. } => {
assert!(
msg.contains("plugin.json"),
"error should mention plugin.json: {msg}"
);
}
other => panic!("expected MindToml for malformed JSON, got: {other:?}"),
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn plugin_manifest_extra_optional_keys_parse_ok() {
let path = write_temp(
r#"{
"name": "rich-plugin",
"version": "0.1.0",
"description": "A rich plugin",
"author": "Alice",
"homepage": "https://example.com",
"license": "MIT",
"keywords": ["skill", "agent"],
"mcpServers": { "tools": { "command": "npx" } },
"hooks": { "install": "npm install" },
"commands": ["cmd1", "cmd2"],
"lineStyle": "round",
"outputStyles": {}
}"#,
"pm-rich",
);
let m = load_plugin_manifest(&path).expect("extra optional keys must be ignored");
assert_eq!(m.name, "rich-plugin");
assert_eq!(m.version.as_deref(), Some("0.1.0"));
assert_eq!(m.description.as_deref(), Some("A rich plugin"));
let _ = std::fs::remove_file(&path);
}
fn make_marketplace(plugins_json: &str) -> String {
format!(r#"{{"name":"Test Market","plugins":[{plugins_json}]}}"#)
}
#[test]
fn marketplace_in_repo_dotslash_string_source() {
let json = make_marketplace(r#"{"name":"p1","source":"./plugins/p1"}"#);
let path = write_temp(&json, "mkt-inrepo");
let m = load_marketplace_manifest(&path).expect("should parse");
assert_eq!(m.name, "Test Market");
let entries = m.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "p1");
assert!(
matches!(&entries[0].source, PluginSource::InRepo { path } if path == "./plugins/p1"),
"expected InRepo, got {:?}",
entries[0].source
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_in_repo_multisegment_path_source() {
let json = make_marketplace(r#"{"name":"p1","source":"plugins/sub/p1"}"#);
let path = write_temp(&json, "mkt-multiseg");
let m = load_marketplace_manifest(&path).expect("should parse");
assert!(
matches!(&m.entries()[0].source, PluginSource::InRepo { path } if path == "plugins/sub/p1"),
"multi-segment path must be InRepo, got {:?}",
m.entries()[0].source
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_external_url_string_source() {
let json = make_marketplace(r#"{"name":"p2","source":"https://github.com/owner/plugin"}"#);
let path = write_temp(&json, "mkt-url");
let m = load_marketplace_manifest(&path).expect("should parse");
assert!(
matches!(
&m.entries()[0].source,
PluginSource::External { spec } if spec == "https://github.com/owner/plugin"
),
"URL source must be External, got {:?}",
m.entries()[0].source
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_external_owner_repo_string_source() {
let json = make_marketplace(r#"{"name":"p3","source":"owner/plugin-repo"}"#);
let path = write_temp(&json, "mkt-ownerrepo");
let m = load_marketplace_manifest(&path).expect("should parse");
assert!(
matches!(
&m.entries()[0].source,
PluginSource::External { spec } if spec == "owner/plugin-repo"
),
"owner/repo must be External, got {:?}",
m.entries()[0].source
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_external_object_form_with_url() {
let json =
make_marketplace(r#"{"name":"p4","source":{"url":"https://github.com/owner/repo"}}"#);
let path = write_temp(&json, "mkt-obj-url");
let m = load_marketplace_manifest(&path).expect("should parse");
assert!(
matches!(
&m.entries()[0].source,
PluginSource::External { spec } if spec == "https://github.com/owner/repo"
),
"object with url must be External, got {:?}",
m.entries()[0].source
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_external_object_form_with_repo() {
let json =
make_marketplace(r#"{"name":"p5","source":{"source":"github","repo":"owner/plugin"}}"#);
let path = write_temp(&json, "mkt-obj-repo");
let m = load_marketplace_manifest(&path).expect("should parse");
assert!(
matches!(
&m.entries()[0].source,
PluginSource::External { spec } if spec == "owner/plugin"
),
"object with repo must be External via owner/repo, got {:?}",
m.entries()[0].source
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_unsafe_in_repo_path_is_rejected() {
let json = make_marketplace(r#"{"name":"bad","source":"../escape"}"#);
let path = write_temp(&json, "mkt-unsafe");
let err = load_marketplace_manifest(&path).unwrap_err();
match err {
MindError::MindToml { msg, .. } => {
assert!(
msg.contains("unsafe") || msg.contains("..") || msg.contains("escape"),
"error must describe the safety violation: {msg}"
);
}
other => panic!("expected MindToml for unsafe path, got: {other:?}"),
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_absolute_in_repo_path_is_rejected() {
let json = make_marketplace(r#"{"name":"bad","source":"/absolute/path"}"#);
let path = write_temp(&json, "mkt-abs");
let err = load_marketplace_manifest(&path).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"absolute in-repo path must be MindToml error: {err:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_garbage_external_spec_is_error() {
let json = make_marketplace(r#"{"name":"bad","source":"github:badspec"}"#);
let path = write_temp(&json, "mkt-garbage");
let err = load_marketplace_manifest(&path).unwrap_err();
assert!(
matches!(err, MindError::InvalidRepoSpec { .. }),
"garbage spec must bubble InvalidRepoSpec: {err:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_entry_metadata_preserved() {
let json = make_marketplace(
r#"{"name":"p","source":"./p","version":"2.0","description":"A plugin"}"#,
);
let path = write_temp(&json, "mkt-meta");
let m = load_marketplace_manifest(&path).expect("should parse");
let e = &m.entries()[0];
assert_eq!(e.version.as_deref(), Some("2.0"));
assert_eq!(e.description.as_deref(), Some("A plugin"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_empty_plugins_list_is_ok() {
let json = r#"{"name":"Empty Market","plugins":[]}"#;
let path = write_temp(json, "mkt-empty");
let m = load_marketplace_manifest(&path).expect("empty plugins list is valid");
assert_eq!(m.name, "Empty Market");
assert!(m.entries().is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_malformed_json_is_mind_toml_error() {
let path = write_temp(r#"{not json"#, "mkt-badjson");
let err = load_marketplace_manifest(&path).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"malformed JSON must be MindToml error: {err:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_object_source_bad_ref_pin_is_rejected() {
let json =
make_marketplace(r#"{"name":"p","source":{"url":"https://x/y.git","ref":"a..b"}}"#);
let path = write_temp(&json, "mkt-obj-badref");
let err = load_marketplace_manifest(&path).unwrap_err();
assert!(
matches!(err, MindError::InvalidRef { .. }),
"an object source with a '..'-bearing ref must bubble InvalidRef: {err:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_object_source_leading_dash_branch_is_rejected() {
let json =
make_marketplace(r#"{"name":"p","source":{"repo":"owner/plugin","branch":"-evil"}}"#);
let path = write_temp(&json, "mkt-obj-dashbranch");
let err = load_marketplace_manifest(&path).unwrap_err();
assert!(
matches!(err, MindError::InvalidRef { .. }),
"a branch beginning with '-' must bubble InvalidRef: {err:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_object_source_valid_ref_pin_is_accepted() {
let json = make_marketplace(
r#"{"name":"p","source":{"url":"https://github.com/owner/repo","ref":"v1.2.3"}}"#,
);
let path = write_temp(&json, "mkt-obj-goodref");
let m = load_marketplace_manifest(&path).expect("a valid ref must parse");
assert!(
matches!(
&m.entries()[0].source,
PluginSource::External { spec } if spec == "https://github.com/owner/repo"
),
"object with url + valid ref must be External, got {:?}",
m.entries()[0].source
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_object_source_missing_url_and_repo_is_error() {
let json = make_marketplace(r#"{"name":"p","source":{"kind":"git"}}"#);
let path = write_temp(&json, "mkt-obj-bad");
let err = load_marketplace_manifest(&path).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"object source without url/repo must be MindToml: {err:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_into_entries_consumes() {
let json = make_marketplace(r#"{"name":"p","source":"./p"}"#);
let path = write_temp(&json, "mkt-consume");
let m = load_marketplace_manifest(&path).expect("should parse");
let entries = m.into_entries();
assert_eq!(entries.len(), 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_entry_empty_name_is_rejected() {
let json = format!(
r#"{{"name":"Test Market","plugins":[{},{}]}}"#,
r#"{"name":"good","source":"./good"}"#, r#"{"name":"","source":"./bad"}"#,
);
let path = write_temp(&json, "mkt-emptyname");
let err = load_marketplace_manifest(&path).unwrap_err();
match err {
MindError::MindToml { msg, .. } => {
assert!(msg.contains("name"), "error must mention 'name': {msg}");
}
other => panic!("expected MindToml for empty entry name, got: {other:?}"),
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn marketplace_entry_whitespace_name_is_rejected() {
let json = make_marketplace(r#"{"name":" ","source":"./p"}"#);
let path = write_temp(&json, "mkt-wsname");
let err = load_marketplace_manifest(&path).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"whitespace-only entry name must be MindToml error: {err:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn skipped_none_returns_none() {
let s = SkippedComponents::default();
assert!(s.summary().is_none(), "no skipped -> None");
}
#[test]
fn skipped_one_hook_singular() {
let s = SkippedComponents {
hooks: 1,
..Default::default()
};
let summary = s.summary().expect("one hook -> Some");
assert!(
summary.contains("1 hook"),
"should say '1 hook' (singular): {summary}"
);
assert!(
!summary.contains("1 hooks"),
"should NOT pluralize for count=1: {summary}"
);
assert!(
summary.contains("not installed"),
"should say 'not installed': {summary}"
);
}
#[test]
fn skipped_multiple_hooks_plural() {
let s = SkippedComponents {
hooks: 3,
..Default::default()
};
let summary = s.summary().expect("3 hooks -> Some");
assert!(
summary.contains("3 hooks"),
"should say '3 hooks' (plural): {summary}"
);
}
#[test]
fn skipped_one_mcp_server_singular() {
let s = SkippedComponents {
mcp_servers: 1,
..Default::default()
};
let summary = s.summary().expect("one mcp server -> Some");
assert!(
summary.contains("1 mcp server"),
"should say '1 mcp server' (singular): {summary}"
);
assert!(
!summary.contains("1 mcp servers"),
"should NOT pluralize mcp server for count=1: {summary}"
);
}
#[test]
fn skipped_multiple_kinds_comma_joined() {
let s = SkippedComponents {
commands: 2,
hooks: 1,
mcp_servers: 3,
..Default::default()
};
let summary = s.summary().expect("multiple kinds -> Some");
assert!(
summary.contains("2 commands"),
"should include commands: {summary}"
);
assert!(
summary.contains("1 hook"),
"should include hook (singular): {summary}"
);
assert!(
summary.contains("3 mcp servers"),
"should include mcp servers: {summary}"
);
assert!(
summary.contains(", "),
"multiple parts must be comma-joined: {summary}"
);
assert!(
summary.contains("not installed (no mind equivalent)"),
"must include standard suffix: {summary}"
);
}
#[test]
fn skipped_output_styles_plural() {
let s = SkippedComponents {
output_styles: 2,
..Default::default()
};
let summary = s.summary().expect("output styles -> Some");
assert!(
summary.contains("2 output styles"),
"should say '2 output styles': {summary}"
);
}
#[test]
fn skipped_all_kinds_renders_all() {
let s = SkippedComponents {
commands: 1,
hooks: 1,
mcp_servers: 1,
lsp_servers: 1,
monitors: 1,
themes: 1,
output_styles: 1,
};
let summary = s.summary().expect("all kinds -> Some");
for expected in &[
"command",
"hook",
"mcp server",
"lsp server",
"monitor",
"theme",
"output style",
] {
assert!(
summary.contains(expected),
"summary must contain {expected:?}: {summary}"
);
}
}
}