#![expect(clippy::unwrap_used, reason = "test scaffolding")]
#![expect(clippy::expect_used, reason = "test scaffolding")]
use std::collections::BTreeMap;
use std::path::PathBuf;
use llmenv::adapter::AgentAdapter;
use llmenv::adapter::claude_code::ClaudeCodeAdapter;
use llmenv::merge::{BundleRef, merge};
use tempfile::tempdir;
fn fixture_bundle(name: &str) -> BundleRef {
BundleRef {
name: name.into(),
path: PathBuf::from(format!("tests/fixtures/bundles/{name}")),
precedence: 1,
}
}
fn empty_native() -> BTreeMap<String, serde_yaml::Value> {
BTreeMap::new()
}
#[test]
fn claude_code_layout() {
let bundles = vec![fixture_bundle("base"), fixture_bundle("rust-defaults")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let tmp = tempdir().expect("tempdir");
let adapter = ClaudeCodeAdapter;
adapter
.materialize(&m, tmp.path())
.expect("materialize claude-code layout");
let mut files: Vec<String> = walkdir::WalkDir::new(tmp.path())
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.map(|e| {
e.path()
.strip_prefix(tmp.path())
.expect("strip prefix")
.to_string_lossy()
.into_owned()
})
.collect();
files.sort();
insta::assert_yaml_snapshot!(files);
}
#[test]
fn claude_md_matches_merged_agents_md() {
let bundles = vec![fixture_bundle("base"), fixture_bundle("rust-defaults")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let claude_md = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).expect("read CLAUDE.md");
assert_eq!(claude_md, m.agents_md);
assert!(!tmp.path().join("AGENTS.md").exists(), "no AGENTS.md");
}
#[test]
fn env_vars_set_claude_config_dir() {
let tmp = tempdir().expect("tempdir");
let vars = ClaudeCodeAdapter
.env_vars(tmp.path())
.expect("utf-8 tempdir");
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].0, "CLAUDE_CONFIG_DIR");
assert_eq!(vars[0].1, tmp.path().to_str().expect("tempdir utf-8"));
}
#[cfg(unix)]
#[test]
fn env_vars_rejects_non_utf8_cache_dir() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
let bad = Path::new(OsStr::from_bytes(b"/tmp/\xff\xfe-not-utf8"));
let err = ClaudeCodeAdapter
.env_vars(bad)
.expect_err("should reject non-utf8 cache dir");
assert!(err.to_string().contains("not valid UTF-8"));
}
#[test]
fn name_is_stable() {
assert_eq!(ClaudeCodeAdapter.name(), "claude-code");
}
#[test]
fn plugins_are_materialized() {
let bundles = vec![fixture_bundle("with-plugin")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let plugin_json = tmp.path().join("plugins/test-plugin/plugin.json");
assert!(
plugin_json.exists(),
"plugin.json should be copied to plugins/<name>/plugin.json"
);
let content = std::fs::read_to_string(&plugin_json).expect("read plugin.json");
assert!(content.contains("test-plugin"), "plugin content preserved");
}
#[test]
fn skills_with_frontmatter_are_validated() {
let bundles = vec![fixture_bundle("base")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let hello_skill = tmp.path().join("skills/hello");
if hello_skill.is_dir() {
let skill_md = hello_skill.join("SKILL.md");
assert!(
skill_md.exists(),
"Each skill should have SKILL.md frontmatter"
);
let content = std::fs::read_to_string(&skill_md).expect("read SKILL.md");
assert!(
content.contains("---"),
"SKILL.md should have YAML frontmatter"
);
}
}
#[test]
fn rejects_skill_missing_skill_md() {
let tmp = tempdir().expect("tempdir");
let skills_dir = tmp.path().join("skills");
std::fs::create_dir_all(&skills_dir).expect("create skills dir");
std::fs::create_dir(skills_dir.join("bad-skill")).expect("create skill dir");
let m = llmenv::merge::MergedManifest {
agents_md: String::new(),
files: Default::default(),
..Default::default()
};
let err = ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect_err("should reject skill directory missing SKILL.md");
assert!(err.to_string().contains("missing SKILL.md"));
}
#[test]
fn rejects_skill_missing_frontmatter_markers() {
let tmp = tempdir().expect("tempdir");
let skills_dir = tmp.path().join("skills");
let skill_dir = skills_dir.join("bad-skill");
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
"name: bad\ndescription: missing markers",
)
.expect("write SKILL.md");
let m = llmenv::merge::MergedManifest {
agents_md: String::new(),
files: Default::default(),
..Default::default()
};
let err = ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect_err("should reject SKILL.md without frontmatter markers");
assert!(err.to_string().contains("missing YAML frontmatter"));
}
#[test]
fn rejects_skill_missing_name_field() {
let tmp = tempdir().expect("tempdir");
let skills_dir = tmp.path().join("skills");
let skill_dir = skills_dir.join("bad-skill");
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\ndescription: no name field\n---\n",
)
.expect("write SKILL.md");
let m = llmenv::merge::MergedManifest {
agents_md: String::new(),
files: Default::default(),
..Default::default()
};
let err = ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect_err("should reject SKILL.md missing name");
assert!(
err.to_string()
.contains("missing required frontmatter fields")
);
}
#[test]
fn rejects_skill_missing_description_field() {
let tmp = tempdir().expect("tempdir");
let skills_dir = tmp.path().join("skills");
let skill_dir = skills_dir.join("bad-skill");
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
std::fs::write(skill_dir.join("SKILL.md"), "---\nname: bad\n---\n").expect("write SKILL.md");
let m = llmenv::merge::MergedManifest {
agents_md: String::new(),
files: Default::default(),
..Default::default()
};
let err = ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect_err("should reject SKILL.md missing description");
assert!(
err.to_string()
.contains("missing required frontmatter fields")
);
}
#[test]
fn rejects_skill_with_invalid_yaml_frontmatter() {
let tmp = tempdir().expect("tempdir");
let skills_dir = tmp.path().join("skills");
let skill_dir = skills_dir.join("bad-skill");
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\ninvalid: yaml: syntax: here\n---\n",
)
.expect("write SKILL.md");
let m = llmenv::merge::MergedManifest {
agents_md: String::new(),
files: Default::default(),
..Default::default()
};
let err = ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect_err("should reject invalid YAML frontmatter");
assert!(err.to_string().contains("invalid YAML frontmatter"));
}
#[test]
fn hooks_generator_renders_bundle_hooks_into_settings_json() {
let bundles = vec![fixture_bundle("base")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_path = tmp.path().join("settings.json");
assert!(settings_path.exists(), "settings.json should be created");
let settings_json = std::fs::read_to_string(&settings_path).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
let hooks = parsed
.get("hooks")
.expect("settings.json should have 'hooks' key");
assert!(hooks.is_object(), "hooks should be an object");
if let Some(hooks_obj) = hooks.as_object() {
for (_event, entries) in hooks_obj {
assert!(
entries.is_array(),
"each event maps to an array of handlers"
);
if let Some(entries_arr) = entries.as_array() {
for entry in entries_arr {
assert!(entry.is_object(), "each handler must be an object");
if let Some(entry_obj) = entry.as_object() {
assert!(
entry_obj.contains_key("hooks"),
"handler entry must have 'hooks' key"
);
if let Some(handlers) = entry_obj.get("hooks").and_then(|h| h.as_array()) {
for handler in handlers {
assert!(
handler.get("type").is_some(),
"handler must have 'type' field"
);
}
}
}
}
}
}
}
}
#[test]
fn hooks_generator_resolves_bundle_relative_paths() {
let bundles = vec![fixture_bundle("base")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_path = tmp.path().join("settings.json");
let settings_json = std::fs::read_to_string(&settings_path).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
if let Some(hooks) = parsed.get("hooks").and_then(|h| h.as_object()) {
for (_event, entries) in hooks {
if let Some(entries_arr) = entries.as_array() {
for entry in entries_arr {
if let Some(handlers) = entry.get("hooks").and_then(|h| h.as_array()) {
for handler in handlers {
if let Some(cmd) = handler.get("command").and_then(|c| c.as_str()) {
assert!(
!cmd.starts_with("hooks/"),
"command paths should not be bundle-relative: {}",
cmd
);
}
}
}
}
}
}
}
}
#[test]
fn native_passthrough_merges_engine_native_into_settings() {
let bundles = vec![fixture_bundle("base")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_path = tmp.path().join("settings.json");
let settings_json = std::fs::read_to_string(&settings_path).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
let perms = parsed
.get("permissions")
.expect("base bundle declares permissions");
assert!(
perms.get("native").is_none(),
"native rules must be flattened into allow/ask/deny, not nested"
);
let deny: Vec<&str> = perms["deny"]
.as_array()
.expect("deny array")
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(
deny.contains(&"Read(./.env)"),
"neutral path rule: {deny:?}"
);
assert!(
deny.contains(&"Read(./.env.*)"),
"neutral path rule: {deny:?}"
);
assert!(
deny.contains(&"WebFetch(domain:internal.example.com)"),
"native rule appended verbatim: {deny:?}"
);
}
#[test]
fn native_rule_overrides_conflicting_neutral_rule() {
let bundles = vec![fixture_bundle("native-wins")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_json =
std::fs::read_to_string(tmp.path().join("settings.json")).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
let allow: Vec<&str> = parsed["permissions"]["allow"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let deny: Vec<&str> = parsed["permissions"]["deny"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
assert!(
!allow.contains(&"WebFetch(domain:blocked.example.com)"),
"neutral allow suppressed by native deny: {allow:?}"
);
assert!(
deny.contains(&"WebFetch(domain:blocked.example.com)"),
"native deny wins: {deny:?}"
);
}
#[test]
fn native_allow_does_not_suppress_neutral_deny() {
let bundles = vec![fixture_bundle("native-allow-vs-neutral-deny")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_json =
std::fs::read_to_string(tmp.path().join("settings.json")).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
let deny: Vec<&str> = parsed["permissions"]["deny"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
assert!(
deny.contains(&"Read(./secrets/**)"),
"neutral deny must not be suppressed by a native allow: {deny:?}"
);
}
#[test]
fn native_hooks_merge_into_settings_hooks() {
let mut native_hooks = BTreeMap::new();
native_hooks.insert(
"claude_code".to_string(),
serde_yaml::from_str::<serde_yaml::Value>(
"PreCompact:\n - hooks:\n - type: command\n command: /bin/engine-only.sh\n",
)
.expect("parse native hooks"),
);
let m = llmenv::merge::MergedManifest {
capabilities: llmenv::config::Capabilities {
native_hooks,
..Default::default()
},
..Default::default()
};
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_json =
std::fs::read_to_string(tmp.path().join("settings.json")).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
let pre_compact = parsed["hooks"]["PreCompact"]
.as_array()
.expect("native PreCompact event rendered into hooks");
let cmd = pre_compact[0]["hooks"][0]["command"]
.as_str()
.expect("native hook command");
assert_eq!(cmd, "/bin/engine-only.sh");
}
#[test]
fn native_plugins_merge_into_settings() {
let mut native_plugins = BTreeMap::new();
native_plugins.insert(
"claude_code".to_string(),
serde_yaml::from_str::<serde_yaml::Value>("enabledPlugins:\n \"extra@market\": true\n")
.expect("parse native plugins"),
);
let m = llmenv::merge::MergedManifest {
capabilities: llmenv::config::Capabilities {
native_plugins,
..Default::default()
},
..Default::default()
};
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_json =
std::fs::read_to_string(tmp.path().join("settings.json")).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
assert_eq!(
parsed["enabledPlugins"]["extra@market"],
serde_json::Value::Bool(true),
"native plugin setting merged into settings.json"
);
}
#[test]
fn native_mcp_servers_merge_into_claude_json() {
let mut native_mcp = BTreeMap::new();
native_mcp.insert(
"claude_code".to_string(),
serde_yaml::from_str::<serde_yaml::Value>(
"mcpServers:\n stdio_server:\n command: native-bin\n",
)
.expect("parse native mcp"),
);
let m = llmenv::merge::MergedManifest {
capabilities: llmenv::config::Capabilities {
native_mcp,
..Default::default()
},
..Default::default()
};
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let claude_json = std::fs::read_to_string(tmp.path().join(".claude.json"))
.expect(".claude.json emitted for native servers");
let parsed: serde_json::Value = serde_json::from_str(&claude_json).expect("parse .claude.json");
assert_eq!(
parsed["mcpServers"]["stdio_server"]["command"],
"native-bin"
);
}
#[test]
fn top_level_native_passthrough_merges_into_settings() {
let mut native = BTreeMap::new();
native.insert(
"claude_code".to_string(),
serde_yaml::from_str::<serde_yaml::Value>(
"alwaysThinkingEnabled: false\noutputStyle: Explanatory\n",
)
.expect("parse native"),
);
let m = llmenv::merge::MergedManifest {
native,
..Default::default()
};
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_json =
std::fs::read_to_string(tmp.path().join("settings.json")).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
assert_eq!(
parsed["alwaysThinkingEnabled"],
serde_json::Value::Bool(false)
);
assert_eq!(
parsed["outputStyle"],
serde_json::Value::String("Explanatory".into())
);
}
#[test]
fn top_level_native_with_modeled_key_hard_errors() {
let mut native = BTreeMap::new();
native.insert(
"claude_code".to_string(),
serde_yaml::from_str::<serde_yaml::Value>("permissions:\n deny: null\n")
.expect("parse native"),
);
let m = llmenv::merge::MergedManifest {
native,
..Default::default()
};
let tmp = tempdir().expect("tempdir");
let err = ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect_err("modeled key in top-level native must hard-error");
let msg = err.to_string();
assert!(
msg.contains("permissions") && msg.contains("native"),
"error must name the offending modeled key and point at native_<feature>: {msg}"
);
}
#[test]
fn top_level_native_with_hooks_key_hard_errors() {
let mut native = BTreeMap::new();
native.insert(
"claude_code".to_string(),
serde_yaml::from_str::<serde_yaml::Value>("hooks:\n PreToolUse: []\n")
.expect("parse native"),
);
let m = llmenv::merge::MergedManifest {
native,
..Default::default()
};
let tmp = tempdir().expect("tempdir");
let err = ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect_err("hooks key in top-level native must hard-error");
assert!(
err.to_string().contains("hooks"),
"error must name the offending modeled key: {err}"
);
}
#[test]
fn session_start_stale_check_hook_emitted_in_settings_json() {
let bundles = vec![fixture_bundle("base")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_path = tmp.path().join("settings.json");
let settings_json = std::fs::read_to_string(&settings_path).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
let session_start = parsed["hooks"]["SessionStart"]
.as_array()
.expect("SessionStart hook registered in settings.json");
assert!(
!session_start.is_empty(),
"SessionStart must carry at least one handler"
);
let handler = &session_start[0]["hooks"][0];
assert_eq!(
handler["type"],
serde_json::Value::String("command".into()),
"stale-check is a command-type handler"
);
let cmd = handler["command"]
.as_str()
.expect("SessionStart handler has a command");
assert!(
cmd.contains("llmenv") && cmd.contains("check-stale"),
"SessionStart command must invoke `llmenv check-stale`: {cmd}"
);
}
#[test]
fn session_start_native_hook_coexists_with_stale_check() {
let mut native_hooks = BTreeMap::new();
native_hooks.insert(
"claude_code".to_string(),
serde_yaml::from_str::<serde_yaml::Value>(
"SessionStart:\n - hooks:\n - type: command\n command: /bin/user-start.sh\n",
)
.expect("parse native hooks"),
);
let m = llmenv::merge::MergedManifest {
capabilities: llmenv::config::Capabilities {
native_hooks,
..Default::default()
},
..Default::default()
};
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_json =
std::fs::read_to_string(tmp.path().join("settings.json")).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
let session_start = parsed["hooks"]["SessionStart"]
.as_array()
.expect("SessionStart array");
let commands: Vec<&str> = session_start
.iter()
.filter_map(|e| e["hooks"][0]["command"].as_str())
.collect();
assert!(
commands.iter().any(|c| c.contains("check-stale")),
"auto stale-check survives: {commands:?}"
);
assert!(
commands.contains(&"/bin/user-start.sh"),
"user SessionStart hook survives: {commands:?}"
);
}
#[test]
fn resolved_servers_land_in_claude_json_mcp_servers() {
use llmenv::mcp::resolve::{ResolvedKind, ResolvedMcp};
let m = llmenv::merge::MergedManifest {
mcps: vec![
ResolvedMcp {
name: "playwright".into(),
kind: ResolvedKind::Stdio {
command: "npx".into(),
args: vec!["playwright".into()],
env: BTreeMap::new(),
},
},
ResolvedMcp {
name: "icm".into(),
kind: ResolvedKind::Remote {
url: "http://still.local:9100/mcp".into(),
transport: llmenv::config::McpTransport::Http,
},
},
],
..Default::default()
};
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
assert!(
!tmp.path().join("mcp.json").exists(),
"legacy mcp.json must not be written"
);
let claude_json =
std::fs::read_to_string(tmp.path().join(".claude.json")).expect(".claude.json emitted");
let parsed: serde_json::Value = serde_json::from_str(&claude_json).expect("parse .claude.json");
let servers = parsed["mcpServers"]
.as_object()
.expect("mcpServers object present");
assert!(servers.contains_key("playwright"), "stdio server present");
assert_eq!(servers["playwright"]["command"], "npx");
assert_eq!(servers["icm"]["type"], "http", "remote carries type");
assert_eq!(servers["icm"]["url"], "http://still.local:9100/mcp");
assert!(
parsed.get("enabledMcpjsonServers").is_none(),
"no approval gate in .claude.json"
);
}
#[test]
fn bundle_mcp_entries_render_into_claude_json() {
use llmenv::mcp::resolve::resolve_bundle_mcps;
use std::collections::BTreeSet;
let bundles = vec![fixture_bundle("with-mcp")];
let mut manifest = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let active_tags: BTreeSet<String> = BTreeSet::new();
manifest.mcps.extend(
resolve_bundle_mcps(&manifest.capabilities.mcp, &active_tags).expect("resolve bundle mcps"),
);
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&manifest, tmp.path())
.expect("materialize");
let claude_json =
std::fs::read_to_string(tmp.path().join(".claude.json")).expect(".claude.json emitted");
let parsed: serde_json::Value = serde_json::from_str(&claude_json).expect("parse");
let servers = parsed["mcpServers"]
.as_object()
.expect("mcpServers present");
assert!(
servers.contains_key("ctx"),
"tagless bundle mcp must be active: {servers:?}"
);
assert!(
!servers.contains_key("playwright"),
"tagged bundle mcp with no matching active tag must be inactive: {servers:?}"
);
}
#[test]
fn bundle_mcp_tagged_entry_active_when_tag_matches() {
use llmenv::mcp::resolve::resolve_bundle_mcps;
use std::collections::BTreeSet;
let bundles = vec![fixture_bundle("with-mcp")];
let mut manifest = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let active_tags: BTreeSet<String> = BTreeSet::from(["feature-playwright".to_string()]);
manifest.mcps.extend(
resolve_bundle_mcps(&manifest.capabilities.mcp, &active_tags).expect("resolve bundle mcps"),
);
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&manifest, tmp.path())
.expect("materialize");
let claude_json =
std::fs::read_to_string(tmp.path().join(".claude.json")).expect(".claude.json emitted");
let parsed: serde_json::Value = serde_json::from_str(&claude_json).expect("parse");
let servers = parsed["mcpServers"]
.as_object()
.expect("mcpServers present");
assert!(
servers.contains_key("ctx"),
"tagless entry must still be active"
);
assert!(
servers.contains_key("playwright"),
"tagged entry must be active when tag matches"
);
}
#[test]
fn global_and_bundle_mcps_both_render() {
use llmenv::mcp::resolve::{ResolvedKind, ResolvedMcp, resolve_bundle_mcps};
use std::collections::BTreeSet;
let bundles = vec![fixture_bundle("with-mcp")];
let mut manifest = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
manifest.mcps.push(ResolvedMcp {
name: "global-tool".into(),
kind: ResolvedKind::Stdio {
command: "global-cmd".into(),
args: vec![],
env: BTreeMap::new(),
},
});
manifest.mcps.extend(
resolve_bundle_mcps(&manifest.capabilities.mcp, &BTreeSet::new())
.expect("resolve bundle mcps"),
);
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&manifest, tmp.path())
.expect("materialize");
let claude_json =
std::fs::read_to_string(tmp.path().join(".claude.json")).expect(".claude.json emitted");
let parsed: serde_json::Value = serde_json::from_str(&claude_json).expect("parse");
let servers = parsed["mcpServers"]
.as_object()
.expect("mcpServers present");
assert!(
servers.contains_key("global-tool"),
"global mcp must render"
);
assert!(
servers.contains_key("ctx"),
"bundle mcp must render alongside global"
);
assert_eq!(servers["global-tool"]["command"], "global-cmd");
}
#[test]
fn native_mcp_enabled_list_is_dropped() {
use llmenv::mcp::resolve::{ResolvedKind, ResolvedMcp};
let mut native_mcp = BTreeMap::new();
native_mcp.insert(
"claude_code".to_string(),
serde_yaml::from_str::<serde_yaml::Value>("enabledMcpjsonServers:\n - only-this\n")
.expect("parse native mcp"),
);
let m = llmenv::merge::MergedManifest {
mcps: vec![ResolvedMcp {
name: "playwright".into(),
kind: ResolvedKind::Stdio {
command: "npx".into(),
args: vec![],
env: BTreeMap::new(),
},
}],
capabilities: llmenv::config::Capabilities {
native_mcp,
..Default::default()
},
..Default::default()
};
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let claude_json =
std::fs::read_to_string(tmp.path().join(".claude.json")).expect(".claude.json emitted");
let parsed: serde_json::Value = serde_json::from_str(&claude_json).expect("parse .claude.json");
assert_eq!(parsed["mcpServers"]["playwright"]["command"], "npx");
assert!(
parsed.get("enabledMcpjsonServers").is_none(),
"enabledMcpjsonServers dropped: {parsed}"
);
}
#[test]
fn auto_memory_disabled_when_icm_active() {
use llmenv::mcp::resolve::{ResolvedKind, ResolvedMcp};
let m = llmenv::merge::MergedManifest {
mcps: vec![ResolvedMcp {
name: "icm".into(),
kind: ResolvedKind::Remote {
url: "http://still.local:9100/mcp".into(),
transport: llmenv::config::McpTransport::Http,
},
}],
..Default::default()
};
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_json =
std::fs::read_to_string(tmp.path().join("settings.json")).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
assert_eq!(
parsed["autoMemoryEnabled"],
serde_json::Value::Bool(false),
"ICM active ⇒ native auto memory disabled"
);
}
#[test]
fn auto_memory_untouched_when_icm_inactive() {
let bundles = vec![fixture_bundle("base")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_json =
std::fs::read_to_string(tmp.path().join("settings.json")).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
assert!(
parsed.get("autoMemoryEnabled").is_none(),
"no ICM ⇒ llmenv must not touch autoMemoryEnabled: {parsed}"
);
}
#[test]
fn user_native_auto_memory_overrides_icm_default() {
use llmenv::mcp::resolve::{ResolvedKind, ResolvedMcp};
let mut native = BTreeMap::new();
native.insert(
"claude_code".to_string(),
serde_yaml::from_str::<serde_yaml::Value>("autoMemoryEnabled: true\n")
.expect("parse native"),
);
let m = llmenv::merge::MergedManifest {
mcps: vec![ResolvedMcp {
name: "icm".into(),
kind: ResolvedKind::Remote {
url: "http://still.local:9100/mcp".into(),
transport: llmenv::config::McpTransport::Http,
},
}],
native,
..Default::default()
};
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_json =
std::fs::read_to_string(tmp.path().join("settings.json")).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
assert_eq!(
parsed["autoMemoryEnabled"],
serde_json::Value::Bool(true),
"explicit user native setting wins over llmenv's ICM default"
);
}
#[test]
fn two_bundles_merge_into_deterministic_settings_json() {
let bundles = vec![fixture_bundle("merge-a"), fixture_bundle("merge-b")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_json =
std::fs::read_to_string(tmp.path().join("settings.json")).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
let pre = parsed["hooks"]["PreToolUse"]
.as_array()
.expect("PreToolUse array");
assert_eq!(pre.len(), 2, "guard.sh deduped, fmt.sh survives: {pre:#?}");
insta::assert_yaml_snapshot!(parsed);
}
#[test]
fn empty_dirs_are_pruned_after_render() {
let tmp = tempdir().expect("tempdir");
let out = tmp.path();
let empty_dir = out.join("subdir").join("empty");
std::fs::create_dir_all(&empty_dir).expect("create empty dir");
let m = llmenv::merge::MergedManifest {
agents_md: String::new(),
files: Default::default(),
..Default::default()
};
ClaudeCodeAdapter.materialize(&m, out).expect("materialize");
assert!(
!empty_dir.exists(),
"empty directory should be pruned after render: {}",
empty_dir.display()
);
assert!(
!out.join("subdir").exists(),
"empty parent directory should also be pruned: {}",
out.join("subdir").display()
);
assert!(out.exists(), "output root must not be removed");
}
#[test]
fn non_empty_dirs_are_preserved_after_render() {
let tmp = tempdir().expect("tempdir");
let source_file = tmp.path().join("src_file.md");
std::fs::write(&source_file, "content").expect("write source file");
let mut files = std::collections::BTreeMap::new();
files.insert(PathBuf::from("kept/file.md"), source_file.clone());
let m = llmenv::merge::MergedManifest {
agents_md: String::new(),
files,
..Default::default()
};
let out_tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, out_tmp.path())
.expect("materialize");
let kept_dir = out_tmp.path().join("kept");
assert!(kept_dir.exists(), "non-empty directory must be preserved");
assert!(
kept_dir.join("file.md").exists(),
"file inside directory must be preserved"
);
}
#[test]
fn bundle_with_no_files_leaves_no_dirs() {
let m = llmenv::merge::MergedManifest {
agents_md: String::new(),
files: Default::default(),
..Default::default()
};
let out_tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, out_tmp.path())
.expect("materialize");
let subdirs: Vec<_> = walkdir::WalkDir::new(out_tmp.path())
.min_depth(1)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_dir())
.map(|e| e.path().to_path_buf())
.collect();
assert!(
subdirs.is_empty(),
"no subdirectories should remain when no bundle contributes files: {subdirs:?}"
);
}
#[test]
fn bundle_order_does_not_change_merged_membership() {
let forward = vec![fixture_bundle("merge-a"), fixture_bundle("merge-b")];
let backward = vec![fixture_bundle("merge-b"), fixture_bundle("merge-a")];
let mf = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&forward,
)
.expect("merge fwd");
let mb = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&backward,
)
.expect("merge bwd");
assert_eq!(mf.capabilities.hooks.len(), mb.capabilities.hooks.len());
let set = |c: &llmenv::config::Capabilities| {
let mut v: Vec<_> = c
.permissions
.allow
.iter()
.map(|r| (r.tool.clone(), r.pattern.clone(), r.paths.clone()))
.collect();
v.sort();
v
};
assert_eq!(set(&mf.capabilities), set(&mb.capabilities));
}
#[test]
fn bundle_relative_hook_paths_are_resolved() {
let bundles = vec![fixture_bundle("with-relative-hook")];
let m = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&bundles,
)
.expect("merge");
let hook = m
.capabilities
.hooks
.iter()
.find(|h| h.event == "PostToolUse")
.expect("PostToolUse hook");
let cmd = hook.handler.command.as_ref().expect("hook command");
assert!(
cmd.contains("hooks/test.sh"),
"merged hook command should be relative: {}",
cmd
);
let tmp = tempdir().expect("tempdir");
ClaudeCodeAdapter
.materialize(&m, tmp.path())
.expect("materialize");
let settings_json =
std::fs::read_to_string(tmp.path().join("settings.json")).expect("read settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&settings_json).expect("parse settings.json");
let post_tool_use = parsed["hooks"]["PostToolUse"]
.as_array()
.expect("PostToolUse array");
let rendered_cmd = post_tool_use[0]["hooks"][0]["command"]
.as_str()
.expect("command string");
assert!(
rendered_cmd.contains("with-relative-hook/hooks/test.sh"),
"adapter should resolve path to absolute: {}",
rendered_cmd
);
}
#[test]
fn emit_hook_context_returns_empty_string_for_empty_input() {
assert_eq!(ClaudeCodeAdapter.emit_hook_context(""), "");
}
#[test]
fn emit_hook_context_wraps_text_in_json() {
let text = "test content";
let output = ClaudeCodeAdapter.emit_hook_context(text);
let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON");
assert!(parsed.is_object());
assert!(parsed.get("hookSpecificOutput").is_some());
assert!(
parsed["hookSpecificOutput"]
.get("additionalContext")
.is_some()
);
}
#[test]
fn emit_hook_context_preserves_markdown_content() {
let text = "## Memory\nContent";
let output = ClaudeCodeAdapter.emit_hook_context(text);
let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON");
let context = parsed["hookSpecificOutput"]["additionalContext"]
.as_str()
.expect("context is string");
assert!(context.contains("## Memory"));
assert!(context.contains("Content"));
}
#[test]
fn emit_hook_context_escapes_special_characters() {
let text = r#"{"injection": "attempt", "quote": "\"", "backslash": "\\"}"#;
let output = ClaudeCodeAdapter.emit_hook_context(text);
let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON");
let context = parsed["hookSpecificOutput"]["additionalContext"]
.as_str()
.expect("context is string");
assert!(context.contains("injection"));
assert!(context.contains("attempt"));
}
#[test]
fn emit_hook_context_wraps_with_barrier_comment() {
let text = "context data";
let output = ClaudeCodeAdapter.emit_hook_context(text);
let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON");
let context = parsed["hookSpecificOutput"]["additionalContext"]
.as_str()
.expect("context is string");
assert!(context.starts_with("[ICM MEMORY CONTEXT"));
assert!(context.contains("context data"));
}
#[test]
fn emit_hook_context_handles_newlines() {
let text = "line1\nline2\nline3";
let output = ClaudeCodeAdapter.emit_hook_context(text);
let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON");
let context = parsed["hookSpecificOutput"]["additionalContext"]
.as_str()
.expect("context is string");
assert!(context.contains("line1"));
assert!(context.contains("line2"));
assert!(context.contains("line3"));
}
#[test]
fn emit_hook_context_handles_unicode() {
let text = "émojis: 🚀 🔒 日本語 中文";
let output = ClaudeCodeAdapter.emit_hook_context(text);
let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON");
let context = parsed["hookSpecificOutput"]["additionalContext"]
.as_str()
.expect("context is string");
assert!(context.contains("émojis"));
assert!(context.contains("🚀"));
assert!(context.contains("日本語"));
}
#[test]
fn materialize_writes_installed_plugins_json_for_external_plugins() {
use llmenv::plugins::resolve::ResolvedPlugin;
let tmp = tempdir().unwrap();
let payload_dir = tmp.path().join("payload");
std::fs::create_dir_all(&payload_dir).unwrap();
let mut manifest = merge(
&llmenv::config::Capabilities::default(),
&empty_native(),
&[],
)
.unwrap();
manifest.plugins = vec![
ResolvedPlugin {
marketplace: "my-market".into(),
plugin: "ext-plugin".into(),
collection: "col".into(),
install_path: Some(payload_dir.to_string_lossy().into_owned()),
git_commit_sha: Some("deadbeef1234567890abcdef".into()),
},
ResolvedPlugin {
marketplace: "my-market".into(),
plugin: "first-party-plugin".into(),
collection: "col".into(),
install_path: None,
git_commit_sha: None,
},
];
let out = tmp.path().join("out");
std::fs::create_dir_all(&out).unwrap();
ClaudeCodeAdapter.materialize(&manifest, &out).unwrap();
let installed_path = out.join("plugins").join("installed_plugins.json");
assert!(
installed_path.exists(),
"installed_plugins.json should be written"
);
let content = std::fs::read_to_string(&installed_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json["version"], 2);
let plugins = json["plugins"].as_object().unwrap();
assert!(
plugins.contains_key("ext-plugin@my-market"),
"external plugin should be in installed_plugins"
);
assert!(
!plugins.contains_key("first-party-plugin@my-market"),
"first-party plugin should not be in installed_plugins"
);
let entry = &plugins["ext-plugin@my-market"][0];
assert_eq!(entry["installPath"], payload_dir.to_string_lossy().as_ref());
assert_eq!(entry["gitCommitSha"], "deadbeef1234567890abcdef");
assert_eq!(entry["scope"], "user");
}