use std::path::Path;
use tempfile::TempDir;
use tokensave::agents::*;
mod common;
use common::{make_install_ctx, make_install_ctx_with_real_bin};
#[test]
fn test_get_all_integrations() {
let all = all_integrations();
assert_eq!(all.len(), 20);
}
#[test]
fn test_available_integrations() {
let ids = available_integrations();
assert!(ids.contains(&"claude"));
assert!(ids.contains(&"copilot"));
assert!(ids.contains(&"codex"));
assert!(ids.contains(&"gemini"));
assert!(ids.contains(&"qwen"));
assert!(ids.contains(&"opencode"));
assert!(ids.contains(&"cursor"));
assert!(ids.contains(&"droid"));
assert!(ids.contains(&"zed"));
assert!(ids.contains(&"cline"));
assert!(ids.contains(&"roo-code"));
assert!(ids.contains(&"antigravity"));
assert!(ids.contains(&"kilo"));
assert!(ids.contains(&"kiro"));
assert!(ids.contains(&"kimi"));
assert!(ids.contains(&"vibe"));
assert!(ids.contains(&"grok"));
assert!(ids.contains(&"pi"));
assert!(ids.contains(&"plank"));
assert!(ids.contains(&"qwen"));
assert!(ids.contains(&"auggie"));
assert_eq!(ids.len(), 20);
}
#[test]
fn test_get_integration_valid() {
for id in &[
"claude",
"opencode",
"codex",
"gemini",
"qwen",
"copilot",
"cursor",
"droid",
"zed",
"cline",
"roo-code",
"antigravity",
"kilo",
"kiro",
"kimi",
"vibe",
"grok",
"pi",
"plank",
"auggie",
] {
let agent = get_integration(id).unwrap();
assert_eq!(agent.id(), *id);
}
}
#[test]
fn test_get_integration_invalid() {
assert!(get_integration("nonexistent").is_err());
assert!(get_integration("").is_err());
assert!(get_integration("CLAUDE").is_err()); }
#[test]
fn test_agent_names_and_ids() {
for agent in all_integrations() {
assert!(!agent.name().is_empty(), "agent name should not be empty");
assert!(!agent.id().is_empty(), "agent id should not be empty");
}
}
#[test]
fn test_agent_names_are_human_readable() {
let expected_names: Vec<(&str, &str)> = vec![
("claude", "Claude Code"),
("copilot", "GitHub Copilot"),
("codex", "Codex CLI"),
("gemini", "Gemini CLI"),
("qwen", "Qwen Code"),
("opencode", "OpenCode"),
("cursor", "Cursor"),
("droid", "Factory Droid"),
("zed", "Zed"),
("cline", "Cline"),
("roo-code", "Roo Code"),
("antigravity", "Antigravity"),
("kilo", "Kilo CLI"),
("kiro", "Kiro"),
("kimi", "Kimi CLI"),
("vibe", "Mistral Vibe"),
("grok", "Grok Build"),
("pi", "Pi"),
("plank", "Plank"),
("auggie", "AugmentCode"),
];
for (id, expected_name) in expected_names {
let agent = get_integration(id).unwrap();
assert_eq!(agent.name(), expected_name, "name mismatch for agent {id}");
}
}
#[test]
fn test_claude_install_creates_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
ClaudeIntegration.install(&ctx).unwrap();
let claude_json = home.join(".claude.json");
assert!(
claude_json.exists(),
"~/.claude.json should exist after install"
);
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&claude_json).unwrap()).unwrap();
assert!(
content.get("mcpServers").is_some(),
"mcpServers key should exist"
);
assert!(
content["mcpServers"]["tokensave"].is_object(),
"mcpServers.tokensave should be an object"
);
let args = content["mcpServers"]["tokensave"]["args"]
.as_array()
.unwrap();
assert!(args.iter().any(|v| v.as_str() == Some("serve")));
let settings_path = home.join(".claude/settings.json");
assert!(
settings_path.exists(),
"settings.json should exist after install"
);
let settings: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(
settings["hooks"]["PreToolUse"].is_array(),
"PreToolUse hook should be an array"
);
assert!(
settings["permissions"]["allow"].is_array(),
"permissions.allow should be an array"
);
assert!(
!home.join(".claude/CLAUDE.md").exists(),
"install should not create/touch CLAUDE.md"
);
let rules_path = home.join(".claude/rules/tokensave.md");
assert!(
rules_path.exists(),
"managed rules file should exist after install"
);
let rules_content = std::fs::read_to_string(&rules_path).unwrap();
assert!(
rules_content.contains("tokensave"),
"managed rules file should mention tokensave"
);
}
#[test]
fn test_gemini_install_creates_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
GeminiIntegration.install(&ctx).unwrap();
let settings_path = home.join(".gemini/settings.json");
assert!(
settings_path.exists(),
"settings.json should exist after install"
);
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(
content["mcpServers"]["tokensave"].is_object(),
"mcpServers.tokensave should exist"
);
assert_eq!(
content["mcpServers"]["tokensave"]["trust"],
serde_json::json!(true),
"gemini should have trust: true"
);
let gemini_md = home.join(".gemini/GEMINI.md");
assert!(gemini_md.exists(), "GEMINI.md should exist after install");
let md_content = std::fs::read_to_string(&gemini_md).unwrap();
assert!(md_content.contains("tokensave"));
}
#[test]
fn test_qwen_install_creates_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
QwenIntegration.install(&ctx).unwrap();
let settings_path = home.join(".qwen/settings.json");
assert!(
settings_path.exists(),
"settings.json should exist after install"
);
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(
content["mcpServers"]["tokensave"].is_object(),
"mcpServers.tokensave should exist"
);
assert_eq!(
content["mcpServers"]["tokensave"]["trust"],
serde_json::json!(true),
"qwen should have trust: true"
);
let qwen_md = home.join(".qwen/QWEN.md");
assert!(qwen_md.exists(), "QWEN.md should exist after install");
let md_content = std::fs::read_to_string(&qwen_md).unwrap();
assert!(md_content.contains("tokensave"));
}
#[test]
fn test_codex_install_creates_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
CodexIntegration.install(&ctx).unwrap();
let config_path = home.join(".codex/config.toml");
assert!(
config_path.exists(),
"config.toml should exist after install"
);
let content = std::fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("[mcp_servers.tokensave]"),
"config.toml should contain [mcp_servers.tokensave]"
);
assert!(
content.contains("\"serve\""),
"config.toml should contain \"serve\" in args"
);
let agents_md = home.join(".codex/AGENTS.md");
assert!(agents_md.exists(), "AGENTS.md should exist after install");
let md_content = std::fs::read_to_string(&agents_md).unwrap();
assert!(md_content.contains("tokensave"));
}
#[test]
fn test_kimi_install_creates_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
KimiIntegration.install(&ctx).unwrap();
let mcp_path = home.join(".kimi/mcp.json");
assert!(mcp_path.exists(), "mcp.json should exist after install");
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&mcp_path).unwrap()).unwrap();
assert!(
content["mcpServers"]["tokensave"].is_object(),
"mcpServers.tokensave should be an object"
);
let args = content["mcpServers"]["tokensave"]["args"]
.as_array()
.unwrap();
assert!(args.iter().any(|v| v.as_str() == Some("serve")));
let agents_md = home.join(".kimi/AGENTS.md");
assert!(agents_md.exists(), "AGENTS.md should exist after install");
let md_content = std::fs::read_to_string(&agents_md).unwrap();
assert!(md_content.contains("tokensave"));
}
#[test]
fn test_kimi_install_then_uninstall() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
KimiIntegration.install(&ctx).unwrap();
let mcp_path = home.join(".kimi/mcp.json");
assert!(mcp_path.exists());
KimiIntegration.uninstall(&ctx).unwrap();
assert!(
!mcp_path.exists(),
"mcp.json with only tokensave should be removed on uninstall"
);
let agents_md = home.join(".kimi/AGENTS.md");
if agents_md.exists() {
let content = std::fs::read_to_string(&agents_md).unwrap();
assert!(
!content.contains("## Prefer tokensave MCP tools"),
"AGENTS.md should not have tokensave rules after uninstall"
);
}
}
#[test]
fn test_kimi_is_detected_and_has_tokensave() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!KimiIntegration.is_detected(home));
assert!(!KimiIntegration.has_tokensave(home));
let ctx = make_install_ctx(home);
KimiIntegration.install(&ctx).unwrap();
assert!(KimiIntegration.is_detected(home));
assert!(KimiIntegration.has_tokensave(home));
}
#[test]
fn test_cursor_install_creates_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
CursorIntegration.install(&ctx).unwrap();
let mcp_path = home.join(".cursor/mcp.json");
assert!(mcp_path.exists(), "mcp.json should exist after install");
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&mcp_path).unwrap()).unwrap();
assert!(content["mcpServers"]["tokensave"].is_object());
let hooks_path = home.join(".cursor/hooks.json");
assert!(hooks_path.exists(), "hooks.json should exist after install");
let hooks: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&hooks_path).unwrap()).unwrap();
let pre = hooks["hooks"]["preToolUse"].as_array().unwrap();
assert!(pre.iter().any(|e| {
e["matcher"].as_str() == Some("Grep|Shell")
&& e["command"]
.as_str()
.is_some_and(|c| c.contains("hook-pre-tool-use"))
}));
}
#[test]
fn test_cursor_install_preserves_foreign_hooks() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let hooks_path = home.join(".cursor/hooks.json");
std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap();
std::fs::write(
&hooks_path,
r#"{
"version": 1,
"hooks": {
"preToolUse": [
{"command": "rtk hook cursor", "matcher": "Shell"},
{"command": "/opt/custom/limit.py", "matcher": "Task"}
]
}
}"#,
)
.unwrap();
let ctx = make_install_ctx(home);
CursorIntegration.install(&ctx).unwrap();
let hooks: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&hooks_path).unwrap()).unwrap();
let pre = hooks["hooks"]["preToolUse"].as_array().unwrap();
assert_eq!(pre.len(), 3, "should append tokensave, keep rtk + custom");
assert!(pre
.iter()
.any(|e| e["command"].as_str() == Some("rtk hook cursor")));
assert!(pre.iter().any(|e| {
e["command"]
.as_str()
.is_some_and(|c| c.contains("hook-pre-tool-use"))
}));
}
#[test]
fn test_cursor_uninstall_removes_only_tokensave_hooks() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
CursorIntegration.install(&ctx).unwrap();
CursorIntegration.uninstall(&ctx).unwrap();
let hooks_path = home.join(".cursor/hooks.json");
assert!(
!hooks_path.exists() || {
let hooks: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&hooks_path).unwrap()).unwrap();
hooks["hooks"]["preToolUse"]
.as_array()
.map(|a| {
!a.iter().any(|e| {
e["command"]
.as_str()
.is_some_and(|c| c.contains("tokensave"))
})
})
.unwrap_or(true)
}
);
}
#[test]
fn test_opencode_install_creates_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
OpenCodeIntegration.install(&ctx).unwrap();
let config_path = home.join(".config/opencode/opencode.json");
assert!(
config_path.exists(),
"opencode.json should exist after install"
);
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
assert!(content["mcp"]["tokensave"].is_object());
}
#[test]
fn test_zed_install_creates_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
ZedIntegration.install(&ctx).unwrap();
#[cfg(target_os = "macos")]
let settings_path = home.join("Library/Application Support/Zed/settings.json");
#[cfg(not(target_os = "macos"))]
let settings_path = home.join(".config/zed/settings.json");
assert!(
settings_path.exists(),
"Zed settings.json should exist after install"
);
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(content["context_servers"]["tokensave"].is_object());
}
#[test]
fn test_cline_install_creates_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
ClineIntegration.install(&ctx).unwrap();
#[cfg(target_os = "macos")]
let settings_path = home.join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json");
#[cfg(target_os = "linux")]
let settings_path = home.join(
".config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
);
#[cfg(target_os = "windows")]
let settings_path = home.join("AppData/Roaming/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json");
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let settings_path = home.join(
".config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
);
assert!(
settings_path.exists(),
"Cline settings should exist after install"
);
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(content["mcpServers"]["tokensave"].is_object());
}
#[test]
fn test_roo_code_install_creates_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
RooCodeIntegration.install(&ctx).unwrap();
#[cfg(target_os = "macos")]
let settings_path = home.join("Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
#[cfg(target_os = "linux")]
let settings_path = home.join(".config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
#[cfg(target_os = "windows")]
let settings_path = home.join("AppData/Roaming/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let settings_path = home.join(".config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
assert!(
settings_path.exists(),
"Roo Code settings should exist after install"
);
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(content["mcpServers"]["tokensave"].is_object());
}
#[test]
fn test_copilot_install_creates_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
CopilotIntegration.install(&ctx).unwrap();
#[cfg(target_os = "macos")]
let vscode_mcp_json = home.join("Library/Application Support/Code/User/mcp.json");
#[cfg(target_os = "linux")]
let vscode_mcp_json = home.join(".config/Code/User/mcp.json");
#[cfg(target_os = "windows")]
let vscode_mcp_json = home.join("AppData/Roaming/Code/User/mcp.json");
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let vscode_mcp_json = home.join(".config/Code/User/mcp.json");
assert!(vscode_mcp_json.exists(), "VS Code mcp.json should exist");
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&vscode_mcp_json).unwrap()).unwrap();
assert!(content["servers"]["tokensave"].is_object());
let cli_config = home.join(".copilot/mcp-config.json");
assert!(cli_config.exists(), "Copilot CLI config should exist");
let cli_content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&cli_config).unwrap()).unwrap();
assert!(cli_content["mcpServers"]["tokensave"].is_object());
}
#[test]
fn test_vibe_install_creates_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
VibeIntegration.install(&ctx).unwrap();
let config_path = home.join(".vibe/config.toml");
assert!(
config_path.exists(),
"config.toml should exist after install"
);
let content = std::fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("name = \"tokensave\""),
"config should contain tokensave MCP server"
);
assert!(
content.contains("transport = \"stdio\""),
"config should use stdio transport"
);
assert!(
content.contains("args = [\"serve\"]"),
"config should have serve arg"
);
let prompt_path = home.join(".vibe/prompts/cli.md");
assert!(
prompt_path.exists(),
"Vibe prompt should exist after install"
);
let prompt = std::fs::read_to_string(&prompt_path).unwrap();
assert!(prompt.contains("tokensave"));
}
#[test]
fn test_claude_install_then_uninstall() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
ClaudeIntegration.install(&ctx).unwrap();
assert!(home.join(".claude.json").exists());
ClaudeIntegration.uninstall(&ctx).unwrap();
if home.join(".claude.json").exists() {
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(home.join(".claude.json")).unwrap())
.unwrap();
let has_tokensave = content
.get("mcpServers")
.and_then(|v| v.get("tokensave"))
.is_some();
assert!(
!has_tokensave,
"tokensave should be removed from .claude.json after uninstall"
);
}
}
#[test]
fn test_gemini_install_then_uninstall() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
GeminiIntegration.install(&ctx).unwrap();
let settings_path = home.join(".gemini/settings.json");
assert!(settings_path.exists());
GeminiIntegration.uninstall(&ctx).unwrap();
if settings_path.exists() {
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
let has_tokensave = content
.get("mcpServers")
.and_then(|v| v.get("tokensave"))
.is_some();
assert!(
!has_tokensave,
"tokensave should be removed from settings.json"
);
}
let gemini_md = home.join(".gemini/GEMINI.md");
if gemini_md.exists() {
let content = std::fs::read_to_string(&gemini_md).unwrap();
assert!(
!content.contains("## Prefer tokensave MCP tools"),
"GEMINI.md should not contain tokensave rules after uninstall"
);
}
}
#[test]
fn test_qwen_install_then_uninstall() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
QwenIntegration.install(&ctx).unwrap();
let settings_path = home.join(".qwen/settings.json");
assert!(settings_path.exists());
QwenIntegration.uninstall(&ctx).unwrap();
if settings_path.exists() {
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
let has_tokensave = content
.get("mcpServers")
.and_then(|v| v.get("tokensave"))
.is_some();
assert!(
!has_tokensave,
"tokensave should be removed from settings.json"
);
}
let qwen_md = home.join(".qwen/QWEN.md");
if qwen_md.exists() {
let content = std::fs::read_to_string(&qwen_md).unwrap();
assert!(
!content.contains("## Prefer tokensave MCP tools"),
"QWEN.md should not contain tokensave rules after uninstall"
);
}
}
#[test]
fn test_codex_install_then_uninstall() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
CodexIntegration.install(&ctx).unwrap();
let config_path = home.join(".codex/config.toml");
assert!(config_path.exists());
CodexIntegration.uninstall(&ctx).unwrap();
assert!(
!config_path.exists(),
"config.toml with only tokensave should be removed on uninstall"
);
let agents_md = home.join(".codex/AGENTS.md");
if agents_md.exists() {
let content = std::fs::read_to_string(&agents_md).unwrap();
assert!(
!content.contains("## Prefer tokensave MCP tools"),
"AGENTS.md should not have tokensave rules after uninstall"
);
}
}
#[test]
fn test_codex_install_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
std::fs::create_dir_all(home.join(".codex")).unwrap();
let config_path = home.join(".codex/config.toml");
let original = "\
model = \"o4-mini\"
approval_policy = \"on-failure\"
[mcp_servers.other]
command = \"other-bin\"
args = [\"--flag\"]
";
std::fs::write(&config_path, original).unwrap();
let ctx = make_install_ctx(home);
CodexIntegration.install(&ctx).unwrap();
let backup = home.join(".codex/config.toml.bak");
assert!(backup.exists(), "install must back up the existing config");
assert_eq!(std::fs::read_to_string(&backup).unwrap(), original);
let new_contents = std::fs::read_to_string(&config_path).unwrap();
let parsed: toml::Table = toml::from_str(&new_contents).unwrap();
assert_eq!(
parsed.get("model").and_then(|v| v.as_str()),
Some("o4-mini"),
"top-level user keys must be preserved"
);
assert_eq!(
parsed.get("approval_policy").and_then(|v| v.as_str()),
Some("on-failure"),
);
let servers = parsed
.get("mcp_servers")
.and_then(|v| v.as_table())
.expect("mcp_servers should still be a table");
assert!(
servers.contains_key("other"),
"pre-existing mcp_servers entries must be preserved"
);
assert!(
servers.contains_key("tokensave"),
"tokensave should be registered alongside existing servers"
);
}
#[test]
fn test_codex_install_refuses_unparseable_config() {
let dir = TempDir::new().unwrap();
let home = dir.path();
std::fs::create_dir_all(home.join(".codex")).unwrap();
let config_path = home.join(".codex/config.toml");
let original = "this is not valid TOML {{{{";
std::fs::write(&config_path, original).unwrap();
let ctx = make_install_ctx(home);
let result = CodexIntegration.install(&ctx);
assert!(
result.is_err(),
"install must fail when existing config.toml is unparseable"
);
assert_eq!(
std::fs::read_to_string(&config_path).unwrap(),
original,
"the broken config must be left untouched so the user can fix it"
);
}
fn assert_install_backs_up_and_preserves(
agent: &dyn AgentIntegration,
home: &Path,
original: &str,
marker: &str,
) {
let config_path = agent
.primary_config_path(home)
.unwrap_or_else(|| panic!("{} must implement primary_config_path", agent.name()));
std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
std::fs::write(&config_path, original).unwrap();
let ctx = make_install_ctx(home);
agent.install(&ctx).expect("install should succeed");
let mut backup = config_path.as_os_str().to_owned();
backup.push(".bak");
let backup = std::path::PathBuf::from(backup);
assert!(
backup.exists(),
"{}: install must back up the existing config to {}",
agent.name(),
backup.display()
);
assert_eq!(
std::fs::read_to_string(&backup).unwrap(),
original,
"{}: backup must contain the exact original bytes",
agent.name()
);
let new = std::fs::read_to_string(&config_path).unwrap();
assert!(
new.contains(marker),
"{}: user's pre-existing content (marker {marker:?}) must be preserved, got:\n{new}",
agent.name(),
);
}
#[test]
fn test_claude_install_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let original = r#"{
"theme": "solarized",
"mcpServers": {
"other": { "command": "other-bin", "args": ["--flag"] }
}
}
"#;
assert_install_backs_up_and_preserves(&ClaudeIntegration, dir.path(), original, "solarized");
}
#[test]
fn test_gemini_install_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let original = r#"{
"theme": "dark",
"mcpServers": { "other": { "command": "other-bin" } }
}
"#;
assert_install_backs_up_and_preserves(&GeminiIntegration, dir.path(), original, "\"theme\"");
}
#[test]
fn test_cursor_install_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let original = r#"{
"mcpServers": { "other": { "command": "other-bin" } }
}
"#;
assert_install_backs_up_and_preserves(&CursorIntegration, dir.path(), original, "other-bin");
}
#[test]
fn test_opencode_install_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let original = r#"{
"$schema": "https://opencode.ai/config.json",
"mcp": { "other": { "type": "local", "command": ["other-bin"] } }
}
"#;
assert_install_backs_up_and_preserves(&OpenCodeIntegration, dir.path(), original, "other-bin");
}
#[test]
fn test_zed_install_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let original = r#"{
"theme": "One Dark",
"context_servers": { "other": { "command": { "path": "other-bin", "args": [] } } }
}
"#;
assert_install_backs_up_and_preserves(&ZedIntegration, dir.path(), original, "One Dark");
}
#[test]
fn test_cline_install_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let original = r#"{
"mcpServers": { "other": { "command": "other-bin" } }
}
"#;
assert_install_backs_up_and_preserves(&ClineIntegration, dir.path(), original, "other-bin");
}
#[test]
fn test_roo_code_install_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let original = r#"{
"mcpServers": { "other": { "command": "other-bin" } }
}
"#;
assert_install_backs_up_and_preserves(&RooCodeIntegration, dir.path(), original, "other-bin");
}
#[test]
fn test_cursor_uninstall_backs_up_config_with_other_content() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
let path = home.join(".cursor/mcp.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let original = r#"{
"mcpServers": {
"tokensave": { "command": "/usr/local/bin/tokensave", "args": ["serve"] },
"other": { "command": "other-bin" }
}
}
"#;
std::fs::write(&path, original).unwrap();
CursorIntegration.uninstall(&ctx).unwrap();
let backup = home.join(".cursor/mcp.json.bak");
assert!(
backup.exists(),
"uninstall must back up the existing config before rewriting it"
);
assert_eq!(
std::fs::read_to_string(&backup).unwrap(),
original,
"backup must contain the exact pre-uninstall bytes"
);
let new = std::fs::read_to_string(&path).unwrap();
assert!(
new.contains("other-bin") && !new.contains("tokensave"),
"uninstall must drop tokensave but keep other servers; got:\n{new}"
);
}
#[test]
fn test_kilo_install_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let original = r#"{
// user comment about their workflow
"mcp": { "other": { "type": "local", "command": ["other-bin"], "enabled": true } }
}
"#;
assert_install_backs_up_and_preserves(&KiloIntegration, dir.path(), original, "other-bin");
}
#[test]
fn test_antigravity_install_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let original = r#"{
"mcpServers": { "other": { "command": "other-bin" } }
}
"#;
assert_install_backs_up_and_preserves(
&AntigravityIntegration,
dir.path(),
original,
"other-bin",
);
}
#[test]
fn test_antigravity_install_writes_cli_plugin() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let bin = "/usr/local/bin/tokensave";
let ctx = InstallContext {
home: home.to_path_buf(),
tokensave_bin: bin.to_string(),
tool_permissions: expected_tool_perms(),
scope: tokensave::agents::InstallScope::Global,
force_permission_style: false,
};
AntigravityIntegration.install(&ctx).expect("install ok");
let ide_path = home.join(".gemini/antigravity/mcp_config.json");
let cli_path = home.join(".gemini/antigravity-cli/plugins/tokensave.json");
assert!(
ide_path.exists(),
"IDE config must be written: {ide_path:?}"
);
assert!(
cli_path.exists(),
"CLI plugin must be written: {cli_path:?}"
);
for path in [&ide_path, &cli_path] {
let body: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let server = body
.get("mcpServers")
.and_then(|v| v.get("tokensave"))
.expect("tokensave entry");
assert_eq!(
server.get("command").and_then(|v| v.as_str()),
Some(bin),
"{path:?} must point at the install bin"
);
assert!(
server
.get("args")
.and_then(|v| v.as_array())
.is_some_and(|a| a.iter().any(|v| v.as_str() == Some("serve"))),
"{path:?} must invoke `serve`"
);
}
}
#[test]
fn test_antigravity_uninstall_removes_both_locations() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let bin = "/usr/local/bin/tokensave";
let ctx = InstallContext {
home: home.to_path_buf(),
tokensave_bin: bin.to_string(),
tool_permissions: expected_tool_perms(),
scope: tokensave::agents::InstallScope::Global,
force_permission_style: false,
};
AntigravityIntegration.install(&ctx).unwrap();
AntigravityIntegration.uninstall(&ctx).unwrap();
let cli_path = home.join(".gemini/antigravity-cli/plugins/tokensave.json");
assert!(
!cli_path.exists(),
"CLI plugin file must be deleted, still exists at {cli_path:?}"
);
let ide_path = home.join(".gemini/antigravity/mcp_config.json");
if ide_path.exists() {
let body: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&ide_path).unwrap()).unwrap();
assert!(
body.get("mcpServers")
.and_then(|v| v.get("tokensave"))
.is_none(),
"tokensave entry must be removed from {ide_path:?}"
);
}
}
#[test]
fn test_copilot_install_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let original = r#"{
"editor.fontSize": 14,
"workbench.colorTheme": "Default Dark+"
}
"#;
assert_install_backs_up_and_preserves(
&CopilotIntegration,
dir.path(),
original,
"Default Dark+",
);
}
#[test]
fn test_every_tested_agent_advertises_primary_config_path() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let agents: Vec<(&dyn AgentIntegration, &str)> = vec![
(&ClaudeIntegration, "claude"),
(&GeminiIntegration, "gemini"),
(&CursorIntegration, "cursor"),
(&OpenCodeIntegration, "opencode"),
(&ZedIntegration, "zed"),
(&ClineIntegration, "cline"),
(&RooCodeIntegration, "roo-code"),
(&CopilotIntegration, "copilot"),
(&KiloIntegration, "kilo"),
(&AntigravityIntegration, "antigravity"),
(&CodexIntegration, "codex"),
(&KiroIntegration, "kiro"),
(&KimiIntegration, "kimi"),
];
for (agent, id) in agents {
let path = agent
.primary_config_path(home)
.unwrap_or_else(|| panic!("{id} must implement primary_config_path"));
assert!(
path.starts_with(home),
"{id}: primary_config_path must be under the home arg, got {}",
path.display()
);
}
}
#[test]
fn test_cursor_install_then_uninstall() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
CursorIntegration.install(&ctx).unwrap();
let mcp_path = home.join(".cursor/mcp.json");
assert!(mcp_path.exists());
CursorIntegration.uninstall(&ctx).unwrap();
if mcp_path.exists() {
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&mcp_path).unwrap()).unwrap();
let has_tokensave = content
.get("mcpServers")
.and_then(|v| v.get("tokensave"))
.is_some();
assert!(!has_tokensave, "tokensave should be removed from mcp.json");
}
}
#[test]
fn test_copilot_install_then_uninstall() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
CopilotIntegration.install(&ctx).unwrap();
CopilotIntegration.uninstall(&ctx).unwrap();
let cli_config = home.join(".copilot/mcp-config.json");
if cli_config.exists() {
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&cli_config).unwrap()).unwrap();
let has_tokensave = content
.get("mcpServers")
.and_then(|v| v.get("tokensave"))
.is_some();
assert!(!has_tokensave);
}
}
#[test]
fn test_vibe_install_then_uninstall() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
VibeIntegration.install(&ctx).unwrap();
VibeIntegration.uninstall(&ctx).unwrap();
let config_path = home.join(".vibe/config.toml");
if config_path.exists() {
let content = std::fs::read_to_string(&config_path).unwrap();
assert!(
!content.contains("name = \"tokensave\""),
"tokensave should be removed from config.toml"
);
}
let prompt_path = home.join(".vibe/prompts/cli.md");
if prompt_path.exists() {
let content = std::fs::read_to_string(&prompt_path).unwrap();
assert!(
!content.contains("tokensave"),
"tokensave rules should be removed from prompt"
);
}
}
#[test]
fn test_healthcheck_claude_clean_install() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx_with_real_bin(home);
ClaudeIntegration.install(&ctx).unwrap();
let mut dc = DoctorCounters::new();
let hctx = HealthcheckContext {
home: home.to_path_buf(),
project_path: home.to_path_buf(),
};
ClaudeIntegration.healthcheck(&mut dc, &hctx);
assert_eq!(dc.issues, 0, "clean Claude install should have no issues");
}
#[test]
fn test_healthcheck_gemini_clean_install() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
GeminiIntegration.install(&ctx).unwrap();
let mut dc = DoctorCounters::new();
let hctx = HealthcheckContext {
home: home.to_path_buf(),
project_path: home.to_path_buf(),
};
GeminiIntegration.healthcheck(&mut dc, &hctx);
assert_eq!(dc.issues, 0, "clean Gemini install should have no issues");
}
#[test]
fn test_healthcheck_codex_after_install() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
CodexIntegration.install(&ctx).unwrap();
let mut dc = DoctorCounters::new();
let hctx = HealthcheckContext {
home: home.to_path_buf(),
project_path: home.to_path_buf(),
};
CodexIntegration.healthcheck(&mut dc, &hctx);
assert_eq!(
dc.issues, 0,
"Codex healthcheck should pass after a clean install"
);
}
#[test]
fn test_healthcheck_cursor_clean_install() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
CursorIntegration.install(&ctx).unwrap();
let mut dc = DoctorCounters::new();
let hctx = HealthcheckContext {
home: home.to_path_buf(),
project_path: home.to_path_buf(),
};
CursorIntegration.healthcheck(&mut dc, &hctx);
assert_eq!(dc.issues, 0, "clean Cursor install should have no issues");
}
#[test]
fn test_healthcheck_opencode_clean_install() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
OpenCodeIntegration.install(&ctx).unwrap();
let mut dc = DoctorCounters::new();
let hctx = HealthcheckContext {
home: home.to_path_buf(),
project_path: home.to_path_buf(),
};
OpenCodeIntegration.healthcheck(&mut dc, &hctx);
assert_eq!(dc.issues, 0, "clean OpenCode install should have no issues");
}
#[test]
fn test_healthcheck_no_install_warns() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let mut dc = DoctorCounters::new();
let hctx = HealthcheckContext {
home: home.to_path_buf(),
project_path: home.to_path_buf(),
};
ClaudeIntegration.healthcheck(&mut dc, &hctx);
assert!(
dc.issues > 0 || dc.warnings > 0,
"healthcheck on empty dir should report issues or warnings"
);
}
#[test]
fn test_doctor_counters() {
let mut dc = DoctorCounters::new();
assert_eq!(dc.issues, 0);
assert_eq!(dc.warnings, 0);
dc.pass("this is fine");
assert_eq!(dc.issues, 0);
assert_eq!(dc.warnings, 0);
dc.fail("something broke");
assert_eq!(dc.issues, 1);
assert_eq!(dc.warnings, 0);
dc.warn("be careful");
assert_eq!(dc.issues, 1);
assert_eq!(dc.warnings, 1);
dc.info("just info");
assert_eq!(dc.issues, 1);
assert_eq!(dc.warnings, 1);
dc.fail("another failure");
assert_eq!(dc.issues, 2);
assert_eq!(dc.warnings, 1);
}
#[test]
fn test_load_json_file_missing() {
let val = load_json_file(Path::new("/nonexistent/file.json"));
assert!(val.is_object());
assert!(val.as_object().unwrap().is_empty());
}
#[test]
fn test_load_json_file_valid() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.json");
std::fs::write(&path, r#"{"key": "value"}"#).unwrap();
let val = load_json_file(&path);
assert_eq!(val["key"], "value");
}
#[test]
fn test_load_json_file_invalid_returns_empty() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "not valid json").unwrap();
let val = load_json_file(&path);
assert!(val.is_object());
assert!(val.as_object().unwrap().is_empty());
}
#[test]
fn test_load_json_file_strict_missing() {
let result = load_json_file_strict(Path::new("/nonexistent/file.json"));
assert!(result.is_ok());
let val = result.unwrap();
assert!(val.is_object());
assert!(val.as_object().unwrap().is_empty());
}
#[test]
fn test_load_json_file_strict_empty_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("empty.json");
std::fs::write(&path, "").unwrap();
let result = load_json_file_strict(&path);
assert!(result.is_ok());
let val = result.unwrap();
assert!(val.as_object().unwrap().is_empty());
}
#[test]
fn test_load_json_file_strict_whitespace_only() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("ws.json");
std::fs::write(&path, " \n \t ").unwrap();
let result = load_json_file_strict(&path);
assert!(result.is_ok());
}
#[test]
fn test_load_json_file_strict_invalid() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "not valid json").unwrap();
assert!(load_json_file_strict(&path).is_err());
}
#[test]
fn test_load_json_file_strict_valid() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("good.json");
std::fs::write(&path, r#"{"hello": "world"}"#).unwrap();
let val = load_json_file_strict(&path).unwrap();
assert_eq!(val["hello"], "world");
}
#[test]
fn test_backup_config_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.json");
std::fs::write(&path, r#"{"original": true}"#).unwrap();
let backup = backup_config_file(&path).unwrap();
assert!(backup.is_some());
let backup_path = backup.unwrap();
assert!(backup_path.exists());
let backup_content = std::fs::read_to_string(&backup_path).unwrap();
assert_eq!(backup_content, r#"{"original": true}"#);
}
#[test]
fn test_backup_config_file_missing() {
let result = backup_config_file(Path::new("/nonexistent/file.json")).unwrap();
assert!(result.is_none());
}
#[test]
fn test_safe_write_json_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("output.json");
let value = serde_json::json!({"hello": "world"});
safe_write_json_file(&path, &value, None).unwrap();
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(content["hello"], "world");
}
#[test]
fn test_safe_write_json_file_creates_parent_dirs() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("deep/nested/dir/output.json");
let value = serde_json::json!({"nested": true});
safe_write_json_file(&path, &value, None).unwrap();
assert!(path.exists());
}
#[test]
fn test_safe_write_json_file_overwrites_existing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("existing.json");
std::fs::write(&path, r#"{"old": true}"#).unwrap();
let value = serde_json::json!({"new": true});
safe_write_json_file(&path, &value, None).unwrap();
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(content["new"], true);
assert!(content.get("old").is_none());
}
#[test]
fn test_write_json_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("write_test.json");
let value = serde_json::json!({"test": 42});
write_json_file(&path, &value).unwrap();
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(content["test"], 42);
}
#[test]
fn test_load_toml_file_missing() {
let val = load_toml_file(Path::new("/nonexistent/file.toml")).unwrap();
assert!(val.is_table());
assert!(val.as_table().unwrap().is_empty());
}
#[test]
fn test_load_toml_file_valid() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.toml");
std::fs::write(&path, "key = \"value\"\nnumber = 42\n").unwrap();
let val = load_toml_file(&path).expect("valid TOML should parse as document");
let table = val.as_table().expect("top-level should be a table");
assert_eq!(table.get("key").and_then(|v| v.as_str()), Some("value"));
assert_eq!(table.get("number").and_then(|v| v.as_integer()), Some(42));
}
#[test]
fn test_load_toml_file_invalid_returns_err() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad.toml");
std::fs::write(&path, "{{{{not valid toml").unwrap();
assert!(
load_toml_file(&path).is_err(),
"unparseable TOML must surface as error, not silently empty"
);
}
#[test]
fn test_load_toml_file_empty_file_returns_empty_table() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("empty.toml");
std::fs::write(&path, "").unwrap();
let val = load_toml_file(&path).expect("empty file should be treated as empty table");
assert!(val.as_table().unwrap().is_empty());
}
#[test]
fn test_write_toml_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("output.toml");
let mut table = toml::map::Map::new();
table.insert("key".to_string(), toml::Value::String("value".to_string()));
let val = toml::Value::Table(table);
write_toml_file(&path, &val).unwrap();
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("key"));
assert!(content.contains("value"));
}
#[test]
fn test_write_toml_file_backs_up_existing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
let original = "preserved = \"keep me\"\n";
std::fs::write(&path, original).unwrap();
let mut table = toml::map::Map::new();
table.insert(
"new".to_string(),
toml::Value::String("content".to_string()),
);
write_toml_file(&path, &toml::Value::Table(table)).unwrap();
let backup = dir.path().join("config.toml.bak");
assert!(
backup.exists(),
"write must create a .bak of the prior file"
);
assert_eq!(
std::fs::read_to_string(&backup).unwrap(),
original,
"the backup must contain the exact previous bytes"
);
}
#[test]
fn test_write_toml_file_no_backup_when_no_prior_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("fresh.toml");
let mut table = toml::map::Map::new();
table.insert("k".to_string(), toml::Value::String("v".to_string()));
write_toml_file(&path, &toml::Value::Table(table)).unwrap();
let backup = dir.path().join("fresh.toml.bak");
assert!(
!backup.exists(),
"no backup should be created on first write"
);
}
#[test]
fn test_load_jsonc_file_missing() {
let val = load_jsonc_file(Path::new("/nonexistent/file.jsonc"));
assert!(val.is_object());
assert!(val.as_object().unwrap().is_empty());
}
#[test]
fn test_load_jsonc_file_with_comments() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.jsonc");
std::fs::write(
&path,
r#"{
// This is a comment
"key": "value", // trailing comment
/* block comment */
"number": 42,
}"#,
)
.unwrap();
let val = load_jsonc_file(&path);
assert_eq!(val["key"], "value");
assert_eq!(val["number"], 42);
}
#[test]
fn test_load_jsonc_file_strict_missing() {
let result = load_jsonc_file_strict(Path::new("/nonexistent/file.jsonc"));
assert!(result.is_ok());
}
#[test]
fn test_load_jsonc_file_strict_with_comments() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.jsonc");
std::fs::write(
&path,
r#"{
// comment
"key": "value"
}"#,
)
.unwrap();
let val = load_jsonc_file_strict(&path).unwrap();
assert_eq!(val["key"], "value");
}
#[test]
fn test_parse_jsonc() {
let input = r#"{
// line comment
"a": 1,
/* block */ "b": 2,
}"#;
let val = parse_jsonc(input);
assert_eq!(val["a"], 1);
assert_eq!(val["b"], 2);
}
#[test]
fn test_is_detected_claude() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!ClaudeIntegration.is_detected(home));
std::fs::create_dir_all(home.join(".claude")).unwrap();
assert!(ClaudeIntegration.is_detected(home));
}
#[test]
fn test_is_detected_codex() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!CodexIntegration.is_detected(home));
std::fs::create_dir_all(home.join(".codex")).unwrap();
assert!(CodexIntegration.is_detected(home));
}
#[test]
fn test_is_detected_gemini() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!GeminiIntegration.is_detected(home));
std::fs::create_dir_all(home.join(".gemini")).unwrap();
assert!(GeminiIntegration.is_detected(home));
}
#[test]
fn test_is_detected_cursor() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!CursorIntegration.is_detected(home));
std::fs::create_dir_all(home.join(".cursor")).unwrap();
assert!(CursorIntegration.is_detected(home));
}
#[test]
fn test_is_detected_opencode() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!OpenCodeIntegration.is_detected(home));
std::fs::create_dir_all(home.join(".config/opencode")).unwrap();
assert!(OpenCodeIntegration.is_detected(home));
}
#[test]
fn test_is_detected_zed() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!ZedIntegration.is_detected(home));
#[cfg(target_os = "macos")]
std::fs::create_dir_all(home.join("Library/Application Support/Zed")).unwrap();
#[cfg(not(target_os = "macos"))]
std::fs::create_dir_all(home.join(".config/zed")).unwrap();
assert!(ZedIntegration.is_detected(home));
}
#[test]
fn test_is_detected_copilot() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!CopilotIntegration.is_detected(home));
std::fs::create_dir_all(home.join(".copilot")).unwrap();
assert!(CopilotIntegration.is_detected(home));
}
#[test]
fn test_has_tokensave_claude() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!ClaudeIntegration.has_tokensave(home));
let ctx = make_install_ctx(home);
ClaudeIntegration.install(&ctx).unwrap();
assert!(ClaudeIntegration.has_tokensave(home));
ClaudeIntegration.uninstall(&ctx).unwrap();
assert!(!ClaudeIntegration.has_tokensave(home));
}
#[test]
fn test_has_tokensave_gemini() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!GeminiIntegration.has_tokensave(home));
let ctx = make_install_ctx(home);
GeminiIntegration.install(&ctx).unwrap();
assert!(GeminiIntegration.has_tokensave(home));
}
#[test]
fn test_has_tokensave_codex() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!CodexIntegration.has_tokensave(home));
let ctx = make_install_ctx(home);
CodexIntegration.install(&ctx).unwrap();
assert!(home.join(".codex/config.toml").exists());
assert!(
CodexIntegration.has_tokensave(home),
"has_tokensave should detect tokensave after a clean install"
);
}
#[test]
fn test_has_tokensave_cursor() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!CursorIntegration.has_tokensave(home));
let ctx = make_install_ctx(home);
CursorIntegration.install(&ctx).unwrap();
assert!(CursorIntegration.has_tokensave(home));
}
#[test]
fn test_has_tokensave_opencode() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!OpenCodeIntegration.has_tokensave(home));
let ctx = make_install_ctx(home);
OpenCodeIntegration.install(&ctx).unwrap();
assert!(OpenCodeIntegration.has_tokensave(home));
}
#[test]
fn test_has_tokensave_copilot() {
let dir = TempDir::new().unwrap();
let home = dir.path();
assert!(!CopilotIntegration.has_tokensave(home));
let ctx = make_install_ctx(home);
CopilotIntegration.install(&ctx).unwrap();
assert!(CopilotIntegration.has_tokensave(home));
}
#[test]
fn test_claude_install_idempotent() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
ClaudeIntegration.install(&ctx).unwrap();
ClaudeIntegration.install(&ctx).unwrap();
let claude_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(home.join(".claude.json")).unwrap()).unwrap();
assert!(claude_json["mcpServers"]["tokensave"].is_object());
}
#[test]
fn test_gemini_install_idempotent() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
GeminiIntegration.install(&ctx).unwrap();
GeminiIntegration.install(&ctx).unwrap();
let settings: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(home.join(".gemini/settings.json")).unwrap())
.unwrap();
assert!(settings["mcpServers"]["tokensave"].is_object());
}
#[test]
fn test_uninstall_without_install_does_not_crash() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
ClaudeIntegration.uninstall(&ctx).unwrap();
GeminiIntegration.uninstall(&ctx).unwrap();
CodexIntegration.uninstall(&ctx).unwrap();
CursorIntegration.uninstall(&ctx).unwrap();
CopilotIntegration.uninstall(&ctx).unwrap();
OpenCodeIntegration.uninstall(&ctx).unwrap();
ZedIntegration.uninstall(&ctx).unwrap();
ClineIntegration.uninstall(&ctx).unwrap();
RooCodeIntegration.uninstall(&ctx).unwrap();
KiroIntegration.uninstall(&ctx).unwrap();
VibeIntegration.uninstall(&ctx).unwrap();
}
#[test]
fn test_claude_install_preserves_existing_claude_json() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let claude_json_path = home.join(".claude.json");
std::fs::write(
&claude_json_path,
r#"{"mcpServers": {"other-server": {"command": "foo"}}, "customKey": 42}"#,
)
.unwrap();
let ctx = make_install_ctx(home);
ClaudeIntegration.install(&ctx).unwrap();
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&claude_json_path).unwrap()).unwrap();
assert!(content["mcpServers"]["tokensave"].is_object());
assert!(content["mcpServers"]["other-server"].is_object());
assert_eq!(content["customKey"], 42);
}
#[test]
fn test_gemini_install_preserves_existing_settings() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let settings_path = home.join(".gemini/settings.json");
std::fs::create_dir_all(home.join(".gemini")).unwrap();
std::fs::write(
&settings_path,
r#"{"mcpServers": {"other": {"command": "bar"}}, "theme": "dark"}"#,
)
.unwrap();
let ctx = make_install_ctx(home);
GeminiIntegration.install(&ctx).unwrap();
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(content["mcpServers"]["tokensave"].is_object());
assert!(content["mcpServers"]["other"].is_object());
assert_eq!(content["theme"], "dark");
}
#[test]
fn test_tool_names_not_empty() {
let names = tool_names();
assert!(!names.is_empty());
for name in &names {
assert!(
name.starts_with("tokensave_"),
"tool name should start with tokensave_: {name}"
);
}
}
#[test]
fn test_read_only_tool_names_excludes_mutating_tools() {
let read_only = read_only_tool_names();
let read_only_set: std::collections::HashSet<&str> =
read_only.iter().map(String::as_str).collect();
let known_tools: std::collections::HashSet<String> = tool_names().into_iter().collect();
assert!(!read_only.is_empty());
for name in &read_only {
assert!(
known_tools.contains(name),
"read-only tool should be a known MCP tool: {name}"
);
}
for mutating in [
"tokensave_str_replace",
"tokensave_multi_str_replace",
"tokensave_insert_at",
"tokensave_ast_grep_rewrite",
"tokensave_session_start",
"tokensave_session_end",
"tokensave_record_decision",
"tokensave_record_code_area",
] {
assert!(
!read_only_set.contains(mutating),
"mutating tool should not be read-only: {mutating}"
);
}
}
#[test]
fn test_expected_tool_perms_not_empty() {
let perms = expected_tool_perms();
assert!(!perms.is_empty());
for perm in &perms {
assert!(
perm.starts_with("mcp__tokensave__"),
"tool perm should start with mcp__tokensave__: {perm}"
);
}
}
#[test]
fn test_tool_perms_match_tool_names() {
let names = tool_names();
let perms = expected_tool_perms();
assert_eq!(
names.len(),
perms.len(),
"tool_names and expected_tool_perms should have same length"
);
for name in &names {
let expected_perm = format!("mcp__tokensave__{name}");
assert!(
perms.contains(&expected_perm),
"missing permission for tool {name}: expected {expected_perm}"
);
}
}
#[test]
fn test_restore_config_backup_restores_content() {
let dir = TempDir::new().unwrap();
let original_path = dir.path().join("config.json");
let backup_path = dir.path().join("config.json.bak");
std::fs::write(&original_path, r#"{"version": 1}"#).unwrap();
std::fs::write(&backup_path, r#"{"version": 1}"#).unwrap();
std::fs::write(&original_path, "CORRUPTED").unwrap();
restore_config_backup(&original_path, &backup_path);
let restored = std::fs::read_to_string(&original_path).unwrap();
assert_eq!(
restored, r#"{"version": 1}"#,
"restored content should match the backup"
);
}
#[test]
fn test_restore_config_backup_to_missing_original() {
let dir = TempDir::new().unwrap();
let original_path = dir.path().join("config.json");
let backup_path = dir.path().join("config.json.bak");
std::fs::write(&backup_path, r#"{"saved": true}"#).unwrap();
restore_config_backup(&original_path, &backup_path);
assert!(
original_path.exists(),
"original should be created from backup"
);
let content = std::fs::read_to_string(&original_path).unwrap();
assert_eq!(content, r#"{"saved": true}"#);
}
#[test]
fn test_restore_config_backup_missing_backup_does_not_panic() {
let dir = TempDir::new().unwrap();
let original_path = dir.path().join("config.json");
let backup_path = dir.path().join("config.json.bak");
std::fs::write(&original_path, "original").unwrap();
restore_config_backup(&original_path, &backup_path);
let content = std::fs::read_to_string(&original_path).unwrap();
assert_eq!(content, "original");
}
#[test]
fn test_which_tokensave_returns_some_or_none() {
let result = which_tokensave();
if let Some(ref path) = result {
assert!(!path.is_empty(), "path should not be empty if Some");
}
}
#[test]
fn test_home_dir_returns_some() {
let result = home_dir();
assert!(
result.is_some(),
"home_dir should return Some on most systems"
);
let home = result.unwrap();
assert!(home.is_absolute(), "home dir should be an absolute path");
}
#[test]
fn test_migrate_installed_agents_skips_when_already_populated() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let mut config = tokensave::user_config::UserConfig::default();
config.installed_agents = vec!["claude".to_string()];
migrate_installed_agents(home, &mut config);
assert_eq!(config.installed_agents, vec!["claude".to_string()]);
}
#[test]
fn test_migrate_installed_agents_detects_installed_agents() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let ctx = make_install_ctx(home);
CopilotIntegration.install(&ctx).unwrap();
let mut config = tokensave::user_config::UserConfig::default();
assert!(config.installed_agents.is_empty());
migrate_installed_agents(home, &mut config);
assert!(
config.installed_agents.contains(&"copilot".to_string()),
"copilot should be detected, got: {:?}",
config.installed_agents
);
}
#[test]
fn test_migrate_installed_agents_empty_home_no_change() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let mut config = tokensave::user_config::UserConfig::default();
migrate_installed_agents(home, &mut config);
assert!(
config.installed_agents.is_empty(),
"installed_agents should remain empty when no agents detected"
);
}
#[test]
fn test_pick_integrations_interactive_no_agents_detected() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let result = pick_integrations_interactive(home, &[]);
assert!(
result.is_err(),
"pick_integrations_interactive should error when no agents detected"
);
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("No supported agents detected"),
"error should mention no agents detected, got: {err_msg}"
);
}
#[test]
fn test_pick_integrations_interactive_single_uninstalled_agent() {
let dir = TempDir::new().unwrap();
let home = dir.path();
std::fs::create_dir_all(home.join(".copilot")).unwrap();
let result = pick_integrations_interactive(home, &[]);
assert!(
result.is_ok(),
"should succeed with single uninstalled agent"
);
let (to_install, to_uninstall) = result.unwrap();
assert_eq!(to_install, vec!["copilot".to_string()]);
assert!(to_uninstall.is_empty());
}
#[test]
fn test_vscode_data_dir_is_under_home() {
let home = Path::new("/fake/home");
let dir = tokensave::agents::vscode_data_dir(home);
assert!(
dir.starts_with("/fake/home"),
"vscode_data_dir should be under home: {}",
dir.display()
);
}
#[test]
fn test_copilot_cli_dir_is_under_home() {
let home = Path::new("/fake/home");
let dir = tokensave::agents::copilot_cli_dir(home);
assert_eq!(
dir,
Path::new("/fake/home/.copilot"),
"copilot_cli_dir should be home/.copilot"
);
}
#[test]
fn test_parse_jsonc_empty_string() {
let val = parse_jsonc("");
assert!(val.is_object());
assert!(val.as_object().unwrap().is_empty());
}
#[test]
fn test_parse_jsonc_only_comments() {
let input = "// just a comment\n/* block */\n";
let val = parse_jsonc(input);
assert!(val.is_object());
assert!(val.as_object().unwrap().is_empty());
}
#[test]
fn test_parse_jsonc_nested_comments() {
let input = r#"{
"a": "hello // not a comment",
/* this is a real comment */
"b": true
}"#;
let val = parse_jsonc(input);
assert_eq!(val["a"].as_str().unwrap(), "hello // not a comment");
assert_eq!(val["b"], true);
}
#[test]
fn test_parse_jsonc_trailing_comma_in_object() {
let input = r#"{"a": 1, "b": 2,}"#;
let val = parse_jsonc(input);
assert_eq!(val["a"], 1);
assert_eq!(val["b"], 2);
}
#[test]
fn test_parse_jsonc_trailing_comma_in_array() {
let input = r#"{"arr": [1, 2, 3,]}"#;
let val = parse_jsonc(input);
let arr = val["arr"].as_array().unwrap();
assert_eq!(arr.len(), 3);
}
#[test]
fn test_backup_and_safe_write_round_trip() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("roundtrip.json");
let initial = serde_json::json!({"name": "tokensave", "version": 1});
safe_write_json_file(&path, &initial, None).unwrap();
let backup = backup_config_file(&path).unwrap();
assert!(backup.is_some());
let backup_path = backup.unwrap();
let updated = serde_json::json!({"name": "tokensave", "version": 2});
safe_write_json_file(&path, &updated, Some(&backup_path)).unwrap();
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(content["version"], 2);
let backup_content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&backup_path).unwrap()).unwrap();
assert_eq!(backup_content["version"], 1);
restore_config_backup(&path, &backup_path);
let restored: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(restored["version"], 1);
}