use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use crate::model::Bundle;
pub const BUNDLE_SUFFIX: &str = ".bundle.yaml";
pub fn load(path: &Path) -> Result<Bundle> {
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let bundle: Bundle = serde_yaml_ng::from_str(&raw)
.with_context(|| format!("parse bundle {}", path.display()))?;
let stem = filename_stem(path).ok_or_else(|| {
anyhow::anyhow!(
"{}: filename must end in `{}`",
path.display(),
BUNDLE_SUFFIX
)
})?;
if stem != bundle.name {
anyhow::bail!(
"{}: filename stem `{}` does not match bundle.name `{}`",
path.display(),
stem,
bundle.name
);
}
Ok(bundle)
}
pub fn discover(source_root: &Path) -> Result<Vec<(PathBuf, Bundle)>> {
let mut out: Vec<(PathBuf, Bundle)> = Vec::new();
if !source_root.exists() {
return Ok(out);
}
let mut paths = Vec::new();
walk(source_root, &mut paths)?;
for path in paths {
let Some(bundle) = load_if_bundle(&path)? else {
continue;
};
out.push((path, bundle));
}
out.sort_by(|a, b| a.1.name.cmp(&b.1.name));
Ok(out)
}
fn load_if_bundle(path: &Path) -> Result<Option<Bundle>> {
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
if !has_schema_field(&raw) {
return Ok(None);
}
let bundle: Bundle = serde_yaml_ng::from_str(&raw)
.with_context(|| format!("parse bundle {}", path.display()))?;
let stem = filename_stem(path).ok_or_else(|| {
anyhow::anyhow!(
"{}: filename must end in `{}`",
path.display(),
BUNDLE_SUFFIX
)
})?;
if stem != bundle.name {
anyhow::bail!(
"{}: filename stem `{}` does not match bundle.name `{}`",
path.display(),
stem,
bundle.name
);
}
Ok(Some(bundle))
}
fn has_schema_field(raw: &str) -> bool {
let Ok(value) = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(raw) else {
return false;
};
value
.as_mapping()
.and_then(|m| m.get(serde_yaml_ng::Value::String("schema".into())))
.is_some_and(|v| v.is_u64() || v.is_i64())
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
let entries = fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with('.'))
{
continue;
}
walk(&path, out)?;
} else if file_type.is_file() && filename_stem(&path).is_some() {
out.push(path);
}
}
Ok(())
}
fn filename_stem(path: &Path) -> Option<&str> {
let name = path.file_name().and_then(|n| n.to_str())?;
name.strip_suffix(BUNDLE_SUFFIX)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write_file(dir: &Path, rel: &str, content: &str) -> PathBuf {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
path
}
const PLATFORM: &str = "schema: 1
name: platform-baseline
description: Baseline rules, skills, and agents for all repositories
license: proprietary
items:
rules:
- api-conventions
- license-awareness
skills:
- create-api-endpoint
agents:
- security-reviewer
metadata:
version: \"1.0.0\"
author: platform-dx
";
#[test]
fn load_round_trips_format_spec_example() {
let tmp = tempfile::tempdir().unwrap();
let path = write_file(tmp.path(), "platform-baseline.bundle.yaml", PLATFORM);
let bundle = load(&path).expect("load");
assert_eq!(bundle.name, "platform-baseline");
assert_eq!(bundle.schema.get(), 1);
assert_eq!(bundle.items.rules.len(), 2);
assert_eq!(bundle.items.skills, vec!["create-api-endpoint"]);
assert_eq!(bundle.items.agents, vec!["security-reviewer"]);
assert!(bundle.requires.is_empty(), "no requires specified");
assert_eq!(
bundle.metadata.as_ref().and_then(|m| m.version.as_deref()),
Some("1.0.0")
);
}
#[test]
fn load_parses_requires_with_and_without_version() {
let content = "schema: 1
name: rust-baseline
description: Rust-specific baseline on top of platform
items:
rules: []
requires:
- { name: platform-baseline, version: \"^1.0.0\" }
- { name: shared-conventions }
";
let tmp = tempfile::tempdir().unwrap();
let path = write_file(tmp.path(), "rust-baseline.bundle.yaml", content);
let bundle = load(&path).expect("load");
assert_eq!(bundle.requires.len(), 2);
assert_eq!(bundle.requires[0].name, "platform-baseline");
assert_eq!(bundle.requires[0].version.as_deref(), Some("^1.0.0"));
assert_eq!(bundle.requires[1].name, "shared-conventions");
assert_eq!(bundle.requires[1].version, None);
}
#[test]
fn load_rejects_string_form_requires() {
let content = "schema: 1
name: bare
description: Should fail
items:
rules: []
requires:
- just-a-string
";
let tmp = tempfile::tempdir().unwrap();
let path = write_file(tmp.path(), "bare.bundle.yaml", content);
let err = load(&path).expect_err("string-form requires must be rejected");
let msg = format!("{:#}", err);
assert!(
msg.contains("requires") || msg.contains("Requires") || msg.contains("expected"),
"expected serde error, got: {msg}"
);
}
#[test]
fn load_rejects_filename_stem_mismatch() {
let content = "schema: 1
name: platform-baseline
description: filename mismatch
items:
rules: []
";
let tmp = tempfile::tempdir().unwrap();
let path = write_file(tmp.path(), "wrong-name.bundle.yaml", content);
let err = load(&path).expect_err("must reject filename mismatch");
let msg = format!("{:#}", err);
assert!(
msg.contains("filename stem") && msg.contains("platform-baseline"),
"got: {msg}"
);
}
#[test]
fn load_accepts_empty_items_when_only_requires() {
let content = "schema: 1
name: meta
description: Composes other bundles
items: {}
requires:
- { name: platform-baseline }
";
let tmp = tempfile::tempdir().unwrap();
let path = write_file(tmp.path(), "meta.bundle.yaml", content);
let bundle = load(&path).expect("load");
assert!(bundle.items.is_empty());
assert_eq!(bundle.requires.len(), 1);
}
#[test]
fn discover_finds_bundles_recursively_anywhere_in_tree() {
let tmp = tempfile::tempdir().unwrap();
write_file(
tmp.path(),
"root-only.bundle.yaml",
&renamed(PLATFORM, "root-only"),
);
write_file(
tmp.path(),
"bundles/in-bundles-dir.bundle.yaml",
&renamed(PLATFORM, "in-bundles-dir"),
);
write_file(
tmp.path(),
"nested/alongside.bundle.yaml",
&renamed(PLATFORM, "alongside"),
);
write_file(
tmp.path(),
".git/skipped.bundle.yaml",
"schema: 1\nname: skipped\ndescription: nope\nitems: {}\n",
);
write_file(tmp.path(), "x/SKILL.md", "---\nname: x\n---\nbody");
write_file(
tmp.path(),
"root-only.bundle.md",
"# Root only — human docs\n",
);
let found = discover(tmp.path()).expect("discover");
let names: Vec<&str> = found.iter().map(|(_, b)| b.name.as_str()).collect();
assert_eq!(
names,
vec!["alongside", "in-bundles-dir", "root-only"],
"deterministic sort by bundle name; sibling .bundle.md ignored"
);
}
#[test]
fn discover_skips_yaml_files_without_schema_field() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "real.bundle.yaml", &renamed(PLATFORM, "real"));
write_file(
tmp.path(),
"unrelated.bundle.yaml",
"foo: bar\nbaz:\n - 1\n - 2\n",
);
let found = discover(tmp.path()).expect("discover");
let names: Vec<&str> = found.iter().map(|(_, b)| b.name.as_str()).collect();
assert_eq!(
names,
vec!["real"],
"schema-less file silently skipped in discovery"
);
}
#[test]
fn discover_errors_when_schema_present_but_otherwise_malformed() {
let tmp = tempfile::tempdir().unwrap();
write_file(
tmp.path(),
"broken.bundle.yaml",
"schema: 1\nname: broken\n# missing required `description` and `items`\n",
);
let err = discover(tmp.path()).expect_err("malformed bundle must surface");
let msg = format!("{:#}", err);
assert!(
msg.contains("parse bundle") || msg.contains("description") || msg.contains("items"),
"expected parse error, got: {msg}"
);
}
#[test]
fn discover_empty_when_root_missing_or_no_bundles() {
let tmp = tempfile::tempdir().unwrap();
let nonexistent = tmp.path().join("does-not-exist");
assert!(discover(&nonexistent).unwrap().is_empty());
let empty = tmp.path().join("empty");
fs::create_dir_all(&empty).unwrap();
assert!(discover(&empty).unwrap().is_empty());
}
fn renamed(template: &str, new_name: &str) -> String {
template.replace("name: platform-baseline", &format!("name: {new_name}"))
}
#[test]
fn load_parses_plugins_with_all_clients() {
let content = "schema: 1
name: with-plugins
description: Bundle with plugins declared
items:
rules:
- license-awareness
plugins:
superpowers:
claude:
source: anthropics/claude-plugins
plugin: superpowers
install_url: https://github.com/obra/superpowers#install
copilot:
source: obra/superpowers-marketplace
plugin: superpowers
install_url: https://github.com/obra/superpowers#install
vscode:
extension: anthropic.superpowers
install_url: https://marketplace.visualstudio.com/items?itemName=anthropic.superpowers
opencode:
module: superpowers-opencode
install_url: https://opencode.ai/plugins/superpowers
";
let tmp = tempfile::tempdir().unwrap();
let path = write_file(tmp.path(), "with-plugins.bundle.yaml", content);
let bundle = load(&path).expect("load");
assert_eq!(bundle.plugins.len(), 1);
let sp = &bundle.plugins["superpowers"];
let claude = sp.claude.as_ref().expect("claude block");
assert_eq!(claude.source, "anthropics/claude-plugins");
assert_eq!(claude.plugin, "superpowers");
assert_eq!(
claude.install_url.as_deref(),
Some("https://github.com/obra/superpowers#install")
);
let copilot = sp.copilot.as_ref().expect("copilot block");
assert_eq!(copilot.source, "obra/superpowers-marketplace");
assert_eq!(copilot.plugin, "superpowers");
assert_eq!(
copilot.install_url.as_deref(),
Some("https://github.com/obra/superpowers#install")
);
let vscode = sp.vscode.as_ref().expect("vscode block");
assert_eq!(vscode.extension, "anthropic.superpowers");
assert_eq!(
vscode.install_url.as_deref(),
Some("https://marketplace.visualstudio.com/items?itemName=anthropic.superpowers")
);
let opencode = sp.opencode.as_ref().expect("opencode block");
assert_eq!(opencode.module, "superpowers-opencode");
assert_eq!(
opencode.install_url.as_deref(),
Some("https://opencode.ai/plugins/superpowers")
);
}
#[test]
fn load_parses_plugins_with_single_client() {
let content = "schema: 1
name: claude-only
description: Plugin for Claude only
items:
skills: []
plugins:
my-plugin:
claude:
source: my-org/marketplace
plugin: my-plugin
";
let tmp = tempfile::tempdir().unwrap();
let path = write_file(tmp.path(), "claude-only.bundle.yaml", content);
let bundle = load(&path).expect("load");
assert_eq!(bundle.plugins.len(), 1);
let plugin = &bundle.plugins["my-plugin"];
assert!(plugin.claude.is_some());
assert!(plugin.vscode.is_none());
assert!(plugin.opencode.is_none());
}
#[test]
fn load_accepts_bundle_without_plugins() {
let tmp = tempfile::tempdir().unwrap();
let path = write_file(tmp.path(), "platform-baseline.bundle.yaml", PLATFORM);
let bundle = load(&path).expect("load");
assert!(bundle.plugins.is_empty());
}
#[test]
fn plugins_round_trips_through_serialize() {
let content = "schema: 1
name: roundtrip
description: Roundtrip test
items:
rules: []
plugins:
example:
vscode:
extension: publisher.example
";
let tmp = tempfile::tempdir().unwrap();
let path = write_file(tmp.path(), "roundtrip.bundle.yaml", content);
let bundle = load(&path).expect("load");
let serialized = serde_yaml_ng::to_string(&bundle).expect("serialize");
let reparsed: Bundle = serde_yaml_ng::from_str(&serialized).expect("reparse");
assert_eq!(bundle.plugins, reparsed.plugins);
}
}