pub mod agents_md;
pub mod capabilities;
pub mod rules;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::config::Capabilities;
use crate::mcp::resolve::ResolvedMcp;
use crate::plugins::resolve::{ResolvedMarketplace, ResolvedPlugin};
use crate::util::{merge_yaml, normalize_yaml};
use capabilities::{CapabilityContributor, merge_capabilities};
use rules::RuleFile;
#[derive(Debug, Clone)]
pub struct BundleRef {
pub name: String,
pub path: PathBuf,
pub precedence: u8,
}
#[derive(Debug, Clone, Default)]
pub struct MergedManifest {
pub agents_md: String,
pub files: BTreeMap<PathBuf, PathBuf>,
pub rules: Vec<RuleFile>,
pub mcps: Vec<ResolvedMcp>,
pub plugins: Vec<ResolvedPlugin>,
pub marketplaces: Vec<ResolvedMarketplace>,
pub capabilities: Capabilities,
pub native: std::collections::BTreeMap<String, serde_yaml::Value>,
}
const COPIED_SUBDIRS: &[&str] = &["skills", "plugins", "hooks"];
const TOP_LEVEL_PRECEDENCE: u8 = u8::MAX;
pub fn merge(
top_level: &Capabilities,
native: &BTreeMap<String, serde_yaml::Value>,
bundles: &[BundleRef],
) -> anyhow::Result<MergedManifest> {
let mut agents_parts = Vec::new();
let mut files = BTreeMap::new();
let mut rule_files: Vec<RuleFile> = Vec::new();
let mut contributors: Vec<CapabilityContributor> = Vec::new();
if !top_level.is_empty() {
contributors.push(CapabilityContributor {
name: "config.yaml".to_string(),
precedence: TOP_LEVEL_PRECEDENCE,
capabilities: top_level.clone(),
});
}
for b in bundles {
let am = b.path.join("AGENTS.md");
if am.exists() {
agents_parts.push((b.name.clone(), std::fs::read_to_string(&am)?));
}
for sub in COPIED_SUBDIRS {
let dir = b.path.join(sub);
if !dir.exists() {
continue;
}
walk(&b.path, &dir, &mut files)?;
}
rule_files.extend(rules::collect_from_bundle(&b.path, &b.name)?);
if let Some(caps) = read_bundle_yaml(&b.path, &b.name)? {
contributors.push(CapabilityContributor {
name: format!("bundle '{}'", b.name),
precedence: b.precedence,
capabilities: caps,
});
}
}
let merged_caps = merge_capabilities(&contributors)?;
let mut merged_native = merged_caps.native.clone();
for (key, value) in native {
match merged_native.get_mut(key) {
Some(existing) => merge_yaml(existing, value.clone()),
None => {
let mut normalized = value.clone();
normalize_yaml(&mut normalized);
merged_native.insert(key.clone(), normalized);
}
}
}
Ok(MergedManifest {
agents_md: agents_md::concat(&agents_parts),
files,
rules: rule_files,
native: merged_native,
capabilities: merged_caps,
..MergedManifest::default()
})
}
fn read_bundle_yaml(bundle_root: &Path, name: &str) -> anyhow::Result<Option<Capabilities>> {
let path = bundle_root.join("bundle.yaml");
if !path.exists() {
return Ok(None);
}
let s = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("bundle '{name}': reading {}: {e}", path.display()))?;
let mut caps: Capabilities = serde_yaml::from_str(&s)
.map_err(|e| anyhow::anyhow!("bundle '{name}': parsing {}: {e}", path.display()))?;
for hook in &mut caps.hooks {
hook.bundle_origin = Some(bundle_root.to_path_buf());
}
Ok(Some(caps))
}
fn walk(
bundle_root: &Path,
dir: &Path,
out: &mut BTreeMap<PathBuf, PathBuf>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let file_type = entry.file_type()?;
let p = entry.path();
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
walk(bundle_root, &p, out)?;
} else if file_type.is_file() {
let rel = p
.strip_prefix(bundle_root)
.map_err(|e| anyhow::anyhow!("path {} not under bundle root: {e}", p.display()))?
.to_path_buf();
out.insert(rel, p);
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use tempfile::tempdir;
#[test]
fn bundle_native_block_appears_in_merged_output() {
let tmp = tempdir().unwrap();
let bundle_dir = tmp.path().join("my-bundle");
std::fs::create_dir_all(&bundle_dir).unwrap();
std::fs::write(
bundle_dir.join("bundle.yaml"),
"native:\n claude_code:\n statusLine: bundle-value\n",
)
.unwrap();
let bundle = BundleRef {
name: "my-bundle".into(),
path: bundle_dir,
precedence: 1,
};
let manifest = merge(&Capabilities::default(), &BTreeMap::new(), &[bundle]).unwrap();
assert!(
manifest.native.contains_key("claude_code"),
"bundle native: block must appear in MergedManifest.native"
);
}
#[test]
fn top_level_native_wins_over_bundle_native_on_collision() {
let tmp = tempdir().unwrap();
let bundle_dir = tmp.path().join("b");
std::fs::create_dir_all(&bundle_dir).unwrap();
std::fs::write(
bundle_dir.join("bundle.yaml"),
"native:\n claude_code:\n key: from-bundle\n",
)
.unwrap();
let bundle = BundleRef {
name: "b".into(),
path: bundle_dir,
precedence: 1,
};
let mut top_native: BTreeMap<String, serde_yaml::Value> = BTreeMap::new();
top_native.insert(
"claude_code".to_string(),
serde_yaml::from_str("key: from-top").unwrap(),
);
let manifest = merge(&Capabilities::default(), &top_native, &[bundle]).unwrap();
let val = manifest.native["claude_code"]
.as_mapping()
.and_then(|m| m.get(serde_yaml::Value::String("key".into())))
.and_then(serde_yaml::Value::as_str)
.expect("key must be present");
assert_eq!(val, "from-top", "top-level native: must win over bundle");
}
#[test]
fn top_level_native_insert_is_normalized() {
let mut top_native: BTreeMap<String, serde_yaml::Value> = BTreeMap::new();
top_native.insert(
"claude_code".to_string(),
serde_yaml::from_str("seq:\n - one\n - two\n").unwrap(),
);
let manifest = merge(&Capabilities::default(), &top_native, &[]).unwrap();
let val = manifest
.native
.get("claude_code")
.expect("claude_code key must be present");
let re_serialized = serde_yaml::to_string(val).expect("must serialize");
assert!(
!re_serialized.contains("!!"),
"normalized value must not contain YAML tags: {re_serialized}"
);
}
}