use std::fs;
use std::io::{BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::{json, Map, Value};
use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect};
use crate::cli::{ClientKind, IdeScope};
const CARTOG_LOGO: &str = r"
___ _
/ __\__ _ _ __| |_ ___ __ _
/ / / _` | '__| __/ _ \ / _` |
/ /__| (_| | | | || (_) | (_| |
\____/\__,_|_| \__\___/ \__, |
|___/
code graph indexer · MCP wiring
";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
Project,
User,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeStrategy {
McpServers,
Mcp,
ContextServers,
VsCodeServers,
CodexToml,
HermesYaml,
}
#[derive(Debug)]
pub struct ClientSpec {
pub kind: ClientKind,
pub scope: Scope,
pub path: PathBuf,
pub strategy: MergeStrategy,
pub args: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct MergeOutcome {
pub new_json: String,
pub action: Action,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Action {
Created,
Updated,
Unchanged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum IdeStatus {
Created,
Updated,
Unchanged,
Skipped,
Error,
}
impl IdeStatus {
fn icon(self) -> &'static str {
match self {
IdeStatus::Created | IdeStatus::Updated | IdeStatus::Unchanged => "+",
IdeStatus::Skipped => "!",
IdeStatus::Error => "x",
}
}
fn from_action(action: Action) -> Self {
match action {
Action::Created => IdeStatus::Created,
Action::Updated => IdeStatus::Updated,
Action::Unchanged => IdeStatus::Unchanged,
}
}
}
#[derive(Debug, Serialize)]
pub struct DiffPair {
pub before: Option<String>,
pub after: String,
}
#[derive(Debug, Serialize)]
pub struct IdeStep {
pub client: String,
pub scope: String,
pub path: String,
pub status: IdeStatus,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub diff: Option<DiffPair>,
}
#[derive(Debug, Serialize, Default)]
pub struct IdeSummary {
pub total: usize,
pub created: usize,
pub updated: usize,
pub unchanged: usize,
pub skipped: usize,
pub error: usize,
}
#[derive(Debug, Serialize)]
pub struct IdeReport {
pub steps: Vec<IdeStep>,
pub summary: IdeSummary,
}
impl IdeReport {
pub fn has_errors(&self) -> bool {
self.summary.error > 0
}
pub fn render_human(&self) -> String {
let mut out = String::new();
for step in &self.steps {
out.push_str(&format!(
"{} {} ({}, {}): {}\n",
step.status.icon(),
step.client,
step.scope,
step.path,
step.message,
));
if let Some(diff) = &step.diff {
if let Some(before) = &diff.before {
out.push_str(" --- before ---\n");
for line in before.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
out.push_str(" --- after ---\n");
for line in diff.after.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
}
let s = &self.summary;
out.push_str(&format!(
"\n{} clients: {} created, {} updated, {} unchanged, {} skipped, {} errors\n",
s.total, s.created, s.updated, s.unchanged, s.skipped, s.error
));
out
}
}
pub fn merge_entry(
existing: Option<&str>,
strategy: MergeStrategy,
args: &[String],
) -> Result<MergeOutcome> {
if strategy == MergeStrategy::CodexToml {
return merge_codex_toml(existing, args, "cartog");
}
if strategy == MergeStrategy::HermesYaml {
return merge_hermes_yaml(existing, args);
}
let trimmed = existing.map(str::trim).unwrap_or("");
let parsed_prev: Option<Value> = if trimmed.is_empty() {
None
} else {
let v = serde_json::from_str(trimmed).context("config file is not valid JSON")?;
match &v {
Value::Object(_) => Some(v),
_ => anyhow::bail!("config root must be a JSON object"),
}
};
let mut root = match &parsed_prev {
Some(Value::Object(m)) => m.clone(),
_ => Map::new(),
};
apply_strategy(&mut root, strategy, args)?;
let new_json = format!("{}\n", serde_json::to_string_pretty(&Value::Object(root))?);
let action = match (existing, parsed_prev) {
(None, _) => Action::Created,
(Some(_), Some(prev)) => {
let canonical_prev = format!("{}\n", serde_json::to_string_pretty(&prev)?);
if canonical_prev == new_json {
Action::Unchanged
} else {
Action::Updated
}
}
(Some(_), None) => Action::Updated,
};
Ok(MergeOutcome { new_json, action })
}
fn apply_strategy(
root: &mut Map<String, Value>,
strategy: MergeStrategy,
args: &[String],
) -> Result<()> {
match strategy {
MergeStrategy::McpServers => {
let servers = ensure_object(root, "mcpServers")?;
servers.insert(
"cartog".into(),
json!({ "command": "cartog", "args": args }),
);
}
MergeStrategy::Mcp => {
let mcp = ensure_object(root, "mcp")?;
let mut command_array: Vec<Value> = Vec::with_capacity(1 + args.len());
command_array.push(Value::String("cartog".into()));
command_array.extend(args.iter().cloned().map(Value::String));
mcp.insert(
"cartog".into(),
json!({
"type": "local",
"command": Value::Array(command_array),
"enabled": true,
}),
);
}
MergeStrategy::ContextServers => {
let servers = ensure_object(root, "context_servers")?;
servers.insert(
"cartog".into(),
json!({ "command": "cartog", "args": args }),
);
}
MergeStrategy::VsCodeServers => {
let servers = ensure_object(root, "servers")?;
servers.insert(
"cartog".into(),
json!({ "type": "stdio", "command": "cartog", "args": args }),
);
}
MergeStrategy::CodexToml => {
anyhow::bail!("internal: CodexToml must go through merge_codex_toml")
}
MergeStrategy::HermesYaml => {
anyhow::bail!("internal: HermesYaml must go through merge_hermes_yaml")
}
}
Ok(())
}
fn merge_codex_toml(
existing: Option<&str>,
args: &[String],
section: &str,
) -> Result<MergeOutcome> {
use toml_edit::{value, Array, DocumentMut, Item, Table};
let mut doc: DocumentMut = match existing {
None => DocumentMut::new(),
Some(s) if s.trim().is_empty() => DocumentMut::new(),
Some(s) => s
.parse::<DocumentMut>()
.context("config file is not valid TOML")?,
};
let canonical_prev = doc.to_string();
if !doc.contains_key("mcp_servers") {
let mut t = Table::new();
t.set_implicit(true);
doc["mcp_servers"] = Item::Table(t);
}
let mcp_servers = doc["mcp_servers"].as_table_mut().ok_or_else(|| {
anyhow::anyhow!("top-level `mcp_servers` is not a TOML table; refusing to overwrite")
})?;
if let Some(existing_section) = mcp_servers.get(section) {
if !existing_section.is_table() {
anyhow::bail!(
"section `[mcp_servers.{section}]` exists but is not a table; refusing to overwrite"
);
}
}
let mut entry = Table::new();
entry["command"] = value("cartog");
let mut arr = Array::new();
for a in args {
arr.push(a.clone());
}
entry["args"] = value(arr);
mcp_servers.insert(section, Item::Table(entry));
let new_json = doc.to_string();
let action = match existing {
None => Action::Created,
Some(_) => {
if new_json == canonical_prev {
Action::Unchanged
} else {
Action::Updated
}
}
};
Ok(MergeOutcome { new_json, action })
}
fn merge_hermes_yaml(existing: Option<&str>, args: &[String]) -> Result<MergeOutcome> {
use serde_norway::{Mapping, Value};
let trimmed = existing.map(str::trim).unwrap_or("");
let mut root: Value = if trimmed.is_empty() {
Value::Mapping(Mapping::new())
} else {
let v: Value = serde_norway::from_str(trimmed).context("config file is not valid YAML")?;
match v {
Value::Mapping(_) => v,
Value::Null => Value::Mapping(Mapping::new()),
_ => anyhow::bail!("config root must be a YAML mapping"),
}
};
let canonical_prev = serde_norway::to_string(&root).context("re-serialize previous YAML")?;
let map = root
.as_mapping_mut()
.expect("root normalized to a mapping above");
let servers_key = Value::String("mcp_servers".into());
let servers = map
.entry(servers_key)
.or_insert_with(|| Value::Mapping(Mapping::new()));
let servers = servers.as_mapping_mut().ok_or_else(|| {
anyhow::anyhow!("top-level `mcp_servers` is not a YAML mapping; refusing to overwrite")
})?;
let mut entry = Mapping::new();
entry.insert(
Value::String("command".into()),
Value::String("cartog".into()),
);
entry.insert(
Value::String("args".into()),
Value::Sequence(args.iter().map(|a| Value::String(a.clone())).collect()),
);
servers.insert(Value::String("cartog".into()), Value::Mapping(entry));
let new_json = serde_norway::to_string(&root).context("serialize merged YAML")?;
let action = match existing {
None => Action::Created,
Some(_) if trimmed.is_empty() => Action::Updated,
Some(_) if new_json == canonical_prev => Action::Unchanged,
Some(_) => Action::Updated,
};
Ok(MergeOutcome { new_json, action })
}
fn codex_section_name(project_root: &Path) -> String {
use sha2::{Digest, Sha256};
let abs = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
let project_slug = abs
.file_name()
.and_then(|s| s.to_str())
.map(slugify)
.unwrap_or_else(|| "project".to_string());
let mut h = Sha256::new();
h.update(abs.as_os_str().as_encoded_bytes());
let digest = h.finalize();
let hash_short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
format!("cartog-{project_slug}-{hash_short}")
}
fn slugify(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
fn ensure_object<'a>(
root: &'a mut Map<String, Value>,
key: &str,
) -> Result<&'a mut Map<String, Value>> {
if let Some(existing) = root.get(key) {
if !matches!(existing, Value::Object(_) | Value::Null) {
anyhow::bail!(
"top-level `{key}` is a {} (expected object); refusing to overwrite",
value_kind(existing),
);
}
}
match root.get(key) {
None | Some(Value::Null) => {
root.insert(key.into(), Value::Object(Map::new()));
}
_ => {}
}
let slot = root
.get_mut(key)
.expect("just inserted or kept an existing entry");
slot.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("internal: ensure_object slot is not Object after fixup"))
}
fn value_kind(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
const CLIENT_CATALOGUE: &[CatalogueEntry] = &[
CatalogueEntry {
kind: ClientKind::ClaudeCode,
scope: Scope::Project,
strategy: MergeStrategy::McpServers,
args_kind: ArgsKind::ServeWithWatch,
},
CatalogueEntry {
kind: ClientKind::ClaudeCode,
scope: Scope::User,
strategy: MergeStrategy::McpServers,
args_kind: ArgsKind::ServeWithWatch,
},
CatalogueEntry {
kind: ClientKind::Cursor,
scope: Scope::Project,
strategy: MergeStrategy::McpServers,
args_kind: ArgsKind::Serve,
},
CatalogueEntry {
kind: ClientKind::Vscode,
scope: Scope::Project,
strategy: MergeStrategy::VsCodeServers,
args_kind: ArgsKind::Serve,
},
CatalogueEntry {
kind: ClientKind::Vscode,
scope: Scope::User,
strategy: MergeStrategy::VsCodeServers,
args_kind: ArgsKind::Serve,
},
CatalogueEntry {
kind: ClientKind::ClaudeDesktop,
scope: Scope::User,
strategy: MergeStrategy::McpServers,
args_kind: ArgsKind::Serve,
},
CatalogueEntry {
kind: ClientKind::Windsurf,
scope: Scope::User,
strategy: MergeStrategy::McpServers,
args_kind: ArgsKind::Serve,
},
CatalogueEntry {
kind: ClientKind::Opencode,
scope: Scope::User,
strategy: MergeStrategy::Mcp,
args_kind: ArgsKind::Serve,
},
CatalogueEntry {
kind: ClientKind::Zed,
scope: Scope::User,
strategy: MergeStrategy::ContextServers,
args_kind: ArgsKind::Serve,
},
CatalogueEntry {
kind: ClientKind::Codex,
scope: Scope::User,
strategy: MergeStrategy::CodexToml,
args_kind: ArgsKind::Serve,
},
CatalogueEntry {
kind: ClientKind::Gemini,
scope: Scope::User,
strategy: MergeStrategy::McpServers,
args_kind: ArgsKind::Serve,
},
CatalogueEntry {
kind: ClientKind::Antigravity,
scope: Scope::User,
strategy: MergeStrategy::McpServers,
args_kind: ArgsKind::Serve,
},
CatalogueEntry {
kind: ClientKind::Kiro,
scope: Scope::Project,
strategy: MergeStrategy::McpServers,
args_kind: ArgsKind::Serve,
},
CatalogueEntry {
kind: ClientKind::Kiro,
scope: Scope::User,
strategy: MergeStrategy::McpServers,
args_kind: ArgsKind::Serve,
},
CatalogueEntry {
kind: ClientKind::Hermes,
scope: Scope::User,
strategy: MergeStrategy::HermesYaml,
args_kind: ArgsKind::Serve,
},
];
struct CatalogueEntry {
kind: ClientKind,
scope: Scope,
strategy: MergeStrategy,
args_kind: ArgsKind,
}
#[derive(Clone, Copy)]
enum ArgsKind {
Serve,
ServeWithWatch,
}
impl ArgsKind {
fn resolve(self, no_watch: bool) -> Vec<String> {
match self {
ArgsKind::Serve => vec!["serve".into()],
ArgsKind::ServeWithWatch if no_watch => vec!["serve".into()],
ArgsKind::ServeWithWatch => vec!["serve".into(), "--watch".into()],
}
}
}
fn project_path(kind: ClientKind, cwd: &Path) -> Option<PathBuf> {
match kind {
ClientKind::ClaudeCode => Some(cwd.join(".mcp.json")),
ClientKind::Cursor => Some(cwd.join(".cursor").join("mcp.json")),
ClientKind::Vscode => Some(cwd.join(".vscode").join("mcp.json")),
ClientKind::Kiro => Some(cwd.join(".kiro").join("settings").join("mcp.json")),
_ => None,
}
}
fn user_path(kind: ClientKind, home: &HomeDirs) -> Option<PathBuf> {
match kind {
ClientKind::ClaudeCode => Some(home.claude_code.clone()),
ClientKind::ClaudeDesktop => Some(home.claude_desktop.clone()),
ClientKind::Codex => Some(home.codex.clone()),
ClientKind::Gemini => Some(home.gemini.clone()),
ClientKind::Opencode => Some(home.opencode.clone()),
ClientKind::Windsurf => Some(home.windsurf.clone()),
ClientKind::Zed => Some(home.zed.clone()),
ClientKind::Antigravity => Some(home.antigravity.clone()),
ClientKind::Hermes => Some(home.hermes.clone()),
ClientKind::Kiro => Some(home.kiro.clone()),
ClientKind::Vscode => Some(home.vscode.clone()),
ClientKind::Cursor => None,
}
}
pub fn build_specs(
client: Option<ClientKind>,
scope: IdeScope,
no_watch: bool,
cwd: &Path,
home_dirs: &HomeDirs,
) -> Vec<ClientSpec> {
CLIENT_CATALOGUE
.iter()
.filter(|e| match scope {
IdeScope::Project => e.scope == Scope::Project,
IdeScope::User => e.scope == Scope::User,
IdeScope::All => true,
})
.filter(|e| client.map_or(true, |c| c == e.kind))
.filter_map(|e| {
let path = match e.scope {
Scope::Project => project_path(e.kind, cwd)?,
Scope::User => user_path(e.kind, home_dirs)?,
};
Some(ClientSpec {
kind: e.kind,
scope: e.scope,
path,
strategy: e.strategy,
args: e.args_kind.resolve(no_watch),
})
})
.collect()
}
#[derive(Debug, Clone)]
pub struct HomeDirs {
pub claude_code: PathBuf,
pub claude_desktop: PathBuf,
pub codex: PathBuf,
pub gemini: PathBuf,
pub windsurf: PathBuf,
pub opencode: PathBuf,
pub zed: PathBuf,
pub antigravity: PathBuf,
pub hermes: PathBuf,
pub kiro: PathBuf,
pub vscode: PathBuf,
}
impl Default for HomeDirs {
fn default() -> Self {
let stub = PathBuf::from(".");
HomeDirs {
claude_code: stub.clone(),
claude_desktop: stub.clone(),
codex: stub.clone(),
gemini: stub.clone(),
windsurf: stub.clone(),
opencode: stub.clone(),
zed: stub.clone(),
antigravity: stub.clone(),
hermes: stub.clone(),
kiro: stub.clone(),
vscode: stub,
}
}
}
impl HomeDirs {
pub fn detect() -> Self {
let Some(base) = directories::BaseDirs::new() else {
return Self::default();
};
let home = base.home_dir().to_path_buf();
let xdg_config = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".config"));
let claude_desktop = if cfg!(target_os = "macos") {
home.join("Library/Application Support/Claude/claude_desktop_config.json")
} else if cfg!(target_os = "windows") {
base.config_dir().join("Claude/claude_desktop_config.json")
} else {
xdg_config.join("Claude/claude_desktop_config.json")
};
let vscode = base.config_dir().join("Code/User/mcp.json");
HomeDirs {
claude_code: home.join(".claude/settings.json"),
claude_desktop,
codex: home.join(".codex/config.toml"),
gemini: home.join(".gemini/settings.json"),
windsurf: home.join(".codeium/windsurf/mcp_config.json"),
opencode: xdg_config.join("opencode/opencode.json"),
zed: xdg_config.join("zed/settings.json"),
antigravity: home.join(".gemini/config/mcp_config.json"),
hermes: home.join(".hermes/config.yaml"),
kiro: home.join(".kiro/settings/mcp.json"),
vscode,
}
}
}
pub fn cmd_ide(
client: Option<ClientKind>,
scope: IdeScope,
yes: bool,
dry_run: bool,
no_watch: bool,
json: bool,
) -> Result<()> {
let interactive =
!yes && !dry_run && !json && client.is_none() && std::io::stdin().is_terminal();
let cwd = std::env::current_dir()?;
let homes = HomeDirs::detect();
let report = if interactive {
let items = picker_items(&cwd, &homes);
match interactive_picker(&items)? {
PickerOutcome::Cancelled => return Ok(()),
PickerOutcome::Selected(chosen) => {
run_ide_for_clients(&chosen, dry_run, no_watch, &cwd, &homes)?
}
}
} else {
run_ide(client, scope, interactive, dry_run, no_watch, &cwd, &homes)?
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", report.render_human());
print_next_steps(&report, dry_run);
}
if report.has_errors() {
std::process::exit(1);
}
Ok(())
}
fn dedupe_preserving_order(clients: Vec<ClientKind>) -> (Vec<ClientKind>, Vec<ClientKind>) {
let mut unique = Vec::with_capacity(clients.len());
let mut dropped = Vec::new();
for c in clients {
if unique.contains(&c) {
dropped.push(c);
} else {
unique.push(c);
}
}
(unique, dropped)
}
fn filter_catalogue_by_clients(
clients: &[ClientKind],
scope: IdeScope,
) -> Vec<(ClientKind, Scope)> {
CLIENT_CATALOGUE
.iter()
.filter(|e| match scope {
IdeScope::Project => e.scope == Scope::Project,
IdeScope::User => e.scope == Scope::User,
IdeScope::All => true,
})
.filter(|e| clients.contains(&e.kind))
.map(|e| (e.kind, e.scope))
.collect()
}
pub fn cmd_install(
clients: Vec<ClientKind>,
scope: IdeScope,
dry_run: bool,
no_watch: bool,
json: bool,
) -> Result<()> {
let cwd = std::env::current_dir()?;
let homes = HomeDirs::detect();
let report = if clients.is_empty() {
run_ide(None, scope, false, dry_run, no_watch, &cwd, &homes)?
} else {
let (clients, duplicates) = dedupe_preserving_order(clients);
if !duplicates.is_empty() {
eprintln!(
"note: duplicate clients ignored: {}",
duplicates
.iter()
.map(|c| format!("{c:?}"))
.collect::<Vec<_>>()
.join(", ")
);
}
let chosen = filter_catalogue_by_clients(&clients, scope);
if chosen.is_empty() {
let requested: Vec<String> = clients.iter().map(|c| format!("{c:?}")).collect();
anyhow::bail!(
"no catalogue entries match clients={:?} at scope={:?}. \
Try --scope all, or pick clients available at this scope.",
requested,
scope,
);
}
run_ide_for_clients(&chosen, dry_run, no_watch, &cwd, &homes)?
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", report.render_human());
print_next_steps(&report, dry_run);
}
if report.has_errors() {
std::process::exit(1);
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct ScopeOption {
pub scope: Scope,
pub path: PathBuf,
pub installed: bool,
pub file_present: bool,
}
#[derive(Debug, Clone)]
pub struct PickerItem {
pub kind: ClientKind,
pub scopes: Vec<ScopeOption>,
}
impl PickerItem {
pub fn any_installed(&self) -> bool {
self.scopes.iter().any(|s| s.installed)
}
fn default_scope(&self) -> Scope {
self.scopes
.iter()
.find(|s| s.installed)
.map(|s| s.scope)
.unwrap_or(self.scopes[0].scope)
}
}
pub enum PickerOutcome {
Selected(Vec<(ClientKind, Scope)>),
Cancelled,
}
fn client_binary(kind: ClientKind) -> Option<&'static str> {
match kind {
ClientKind::ClaudeCode => Some("claude"),
ClientKind::Codex => Some("codex"),
ClientKind::Gemini => Some("gemini"),
ClientKind::Opencode => Some("opencode"),
ClientKind::Windsurf => Some("windsurf"),
ClientKind::Zed => Some("zed"),
ClientKind::Hermes => Some("hermes"),
ClientKind::Kiro => Some("kiro"),
ClientKind::ClaudeDesktop
| ClientKind::Cursor
| ClientKind::Vscode
| ClientKind::Antigravity => None,
}
}
fn binary_in(paths: &std::ffi::OsStr, name: &str) -> bool {
let mut candidates = vec![name.to_string()];
if cfg!(windows) {
let pathext =
std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
for ext in pathext.split(';').filter(|e| !e.is_empty()) {
candidates.push(format!("{name}{}", ext.to_ascii_lowercase()));
}
}
std::env::split_paths(paths).any(|dir| candidates.iter().any(|c| dir.join(c).is_file()))
}
fn client_installed(
kind: ClientKind,
scope: Scope,
path: &Path,
path_env: Option<&std::ffi::OsStr>,
) -> bool {
if scope == Scope::Project {
return true;
}
if path.is_absolute() {
if let (Some(bin), Some(p)) = (client_binary(kind), path_env) {
if binary_in(p, bin) {
return true;
}
}
}
path.parent().is_some_and(Path::exists)
}
pub fn picker_items(cwd: &Path, homes: &HomeDirs) -> Vec<PickerItem> {
let path_env = std::env::var_os("PATH");
let mut by_kind: Vec<PickerItem> = Vec::new();
for entry in CLIENT_CATALOGUE.iter() {
let path = match entry.scope {
Scope::Project => match project_path(entry.kind, cwd) {
Some(p) => p,
None => continue,
},
Scope::User => match user_path(entry.kind, homes) {
Some(p) => p,
None => continue,
},
};
let installed = client_installed(entry.kind, entry.scope, &path, path_env.as_deref());
let opt = ScopeOption {
scope: entry.scope,
file_present: path.exists(),
installed,
path,
};
match by_kind.iter_mut().find(|i| i.kind == entry.kind) {
Some(existing) => existing.scopes.push(opt),
None => by_kind.push(PickerItem {
kind: entry.kind,
scopes: vec![opt],
}),
}
}
by_kind
}
pub fn format_picker_label(item: &PickerItem) -> String {
let scope_hint = if item.scopes.len() > 1 {
"project + user available".to_string()
} else {
match item.scopes[0].scope {
Scope::Project => "project".to_string(),
Scope::User => "user".to_string(),
}
};
let status = if item.scopes.len() == 1 {
scope_option_status(&item.scopes[0])
} else {
item.scopes
.iter()
.map(scope_option_status)
.find(|s| *s != "not installed")
.unwrap_or("not installed")
};
format!(
"{name:<16} {hint:<28} {status}",
name = client_display_name(item.kind),
hint = scope_hint,
status = status,
)
}
fn scope_option_status(opt: &ScopeOption) -> &'static str {
match (opt.installed, opt.file_present) {
(false, _) => "not installed",
(true, true) => "present, will merge",
(true, false) => "will create",
}
}
fn home_relative(path: &Path) -> String {
if let Some(home) = directories::BaseDirs::new().map(|b| b.home_dir().to_path_buf()) {
if let Ok(rest) = path.strip_prefix(&home) {
return format!("~/{}", rest.display());
}
}
path.display().to_string()
}
fn interactive_picker(items: &[PickerItem]) -> Result<PickerOutcome> {
eprintln!("{CARTOG_LOGO}");
if items.is_empty() {
eprintln!("No MCP-aware clients are known to cartog. Nothing to configure.");
return Ok(PickerOutcome::Cancelled);
}
let theme = ColorfulTheme::default();
let labels: Vec<String> = items.iter().map(format_picker_label).collect();
let defaults: Vec<bool> = items.iter().map(PickerItem::any_installed).collect();
let selection = MultiSelect::with_theme(&theme)
.with_prompt("Step 1 — Which clients to configure? (space to toggle, enter to confirm)")
.items(&labels)
.defaults(&defaults)
.interact_opt()
.context("interactive picker failed")?;
let Some(indices) = selection else {
return Ok(PickerOutcome::Cancelled);
};
if indices.is_empty() {
eprintln!("Nothing selected.");
return Ok(PickerOutcome::Cancelled);
}
let mut chosen: Vec<(ClientKind, Scope)> = Vec::with_capacity(indices.len());
for &i in &indices {
let item = &items[i];
let scope = if item.scopes.len() == 1 {
item.scopes[0].scope
} else {
prompt_scope(&theme, item)?
};
chosen.push((item.kind, scope));
}
eprintln!(
"\nStep 3 — Confirm. Will configure {} client(s):",
chosen.len()
);
for (kind, scope) in &chosen {
let item = items.iter().find(|i| i.kind == *kind).unwrap();
let opt = item.scopes.iter().find(|s| s.scope == *scope).unwrap();
eprintln!(
" · {name:<16} {scope:<8} {path}",
name = client_display_name(*kind),
scope = scope_label(*scope),
path = home_relative(&opt.path),
);
}
let confirmed = Confirm::with_theme(&theme)
.with_prompt("Apply?")
.default(true)
.interact_opt()
.context("confirmation failed")?
.unwrap_or(false);
if !confirmed {
eprintln!("Aborted, no files were modified.");
return Ok(PickerOutcome::Cancelled);
}
Ok(PickerOutcome::Selected(chosen))
}
fn prompt_scope(theme: &ColorfulTheme, item: &PickerItem) -> Result<Scope> {
use dialoguer::Select;
let options: Vec<String> = item
.scopes
.iter()
.map(|opt| {
format!(
"{scope:<8} {path} ({status})",
scope = scope_label(opt.scope),
path = home_relative(&opt.path),
status = scope_option_status(opt),
)
})
.collect();
let default_idx = item
.scopes
.iter()
.position(|s| s.scope == item.default_scope())
.unwrap_or(0);
let idx = Select::with_theme(theme)
.with_prompt(format!(
"Step 2 — Where should {} write its MCP entry?",
client_display_name(item.kind)
))
.items(&options)
.default(default_idx)
.interact()
.context("scope prompt failed")?;
Ok(item.scopes[idx].scope)
}
fn scope_label(scope: Scope) -> &'static str {
match scope {
Scope::Project => "project",
Scope::User => "user",
}
}
fn client_display_name(kind: ClientKind) -> &'static str {
match kind {
ClientKind::ClaudeCode => "Claude Code",
ClientKind::ClaudeDesktop => "Claude Desktop",
ClientKind::Cursor => "Cursor",
ClientKind::Vscode => "VS Code",
ClientKind::Windsurf => "Windsurf",
ClientKind::Opencode => "OpenCode",
ClientKind::Zed => "Zed",
ClientKind::Codex => "Codex CLI",
ClientKind::Gemini => "Gemini CLI",
ClientKind::Antigravity => "Antigravity",
ClientKind::Hermes => "Hermes Agent",
ClientKind::Kiro => "Kiro",
}
}
fn run_ide_for_clients(
chosen: &[(ClientKind, Scope)],
dry_run: bool,
no_watch: bool,
cwd: &Path,
homes: &HomeDirs,
) -> Result<IdeReport> {
let specs: Vec<ClientSpec> = chosen
.iter()
.filter_map(|&(kind, scope)| spec_for(kind, scope, no_watch, cwd, homes))
.collect();
let mut steps = Vec::with_capacity(specs.len());
let mut summary = IdeSummary::default();
for spec in specs {
let step = process_spec(&spec, cwd, false, dry_run, false);
summary.total += 1;
match step.status {
IdeStatus::Created => summary.created += 1,
IdeStatus::Updated => summary.updated += 1,
IdeStatus::Unchanged => summary.unchanged += 1,
IdeStatus::Skipped => summary.skipped += 1,
IdeStatus::Error => summary.error += 1,
}
steps.push(step);
}
Ok(IdeReport { steps, summary })
}
fn spec_for(
kind: ClientKind,
scope: Scope,
no_watch: bool,
cwd: &Path,
homes: &HomeDirs,
) -> Option<ClientSpec> {
let entry = CLIENT_CATALOGUE
.iter()
.find(|e| e.kind == kind && e.scope == scope)?;
let path = match entry.scope {
Scope::Project => project_path(entry.kind, cwd)?,
Scope::User => user_path(entry.kind, homes)?,
};
Some(ClientSpec {
kind: entry.kind,
scope: entry.scope,
path,
strategy: entry.strategy,
args: entry.args_kind.resolve(no_watch),
})
}
fn print_next_steps(report: &IdeReport, dry_run: bool) {
if dry_run {
println!("Dry run only. Re-run without --dry-run to apply.");
return;
}
let s = &report.summary;
if s.created + s.updated > 0 {
println!(
"Next: open your editor and try a cartog tool (e.g. `search`, `refs`). \
Re-run `cartog ide` after installing more editors."
);
} else if s.skipped > 0 && s.created + s.updated + s.unchanged == 0 {
println!(
"No MCP clients were configured. Install an MCP-aware editor \
(Claude Code, Cursor, Windsurf, Zed) and re-run `cartog ide`."
);
}
}
pub fn run_ide(
client: Option<ClientKind>,
scope: IdeScope,
interactive: bool,
dry_run: bool,
no_watch: bool,
cwd: &Path,
homes: &HomeDirs,
) -> Result<IdeReport> {
let specs = build_specs(client, scope, no_watch, cwd, homes);
if specs.is_empty() {
anyhow::bail!(
"no clients match the requested filter ({:?} + scope={:?}). \
Check `--client` and `--scope` are compatible \
(e.g. claude-code and cursor are project-scoped only).",
client,
scope,
);
}
let mut steps = Vec::with_capacity(specs.len());
let mut summary = IdeSummary::default();
let auto_only = client.is_none();
for spec in specs {
let step = process_spec(&spec, cwd, interactive, dry_run, auto_only);
match step.status {
IdeStatus::Created => summary.created += 1,
IdeStatus::Updated => summary.updated += 1,
IdeStatus::Unchanged => summary.unchanged += 1,
IdeStatus::Skipped => summary.skipped += 1,
IdeStatus::Error => summary.error += 1,
}
summary.total += 1;
steps.push(step);
}
Ok(IdeReport { steps, summary })
}
fn process_spec(
spec: &ClientSpec,
cwd: &Path,
interactive: bool,
dry_run: bool,
auto_only: bool,
) -> IdeStep {
let client = client_name(spec.kind).to_string();
let scope = match spec.scope {
Scope::Project => "project",
Scope::User => "user",
}
.to_string();
let path = spec.path.display().to_string();
let make = |status: IdeStatus, message: String, diff: Option<DiffPair>| IdeStep {
client: client.clone(),
scope: scope.clone(),
path: path.clone(),
status,
message,
diff,
};
if auto_only
&& spec.scope == Scope::User
&& !client_installed(
spec.kind,
spec.scope,
&spec.path,
std::env::var_os("PATH").as_deref(),
)
{
return make(
IdeStatus::Skipped,
"client not detected (no CLI on PATH, no config directory)".into(),
None,
);
}
let existing = match fs::read_to_string(&spec.path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return make(
IdeStatus::Error,
format!("could not read existing config: {e}"),
None,
);
}
};
let merge_result = if spec.strategy == MergeStrategy::CodexToml {
let section = codex_section_name(cwd);
merge_codex_toml(existing.as_deref(), &spec.args, §ion)
} else {
merge_entry(existing.as_deref(), spec.strategy, &spec.args)
};
let outcome = match merge_result {
Ok(o) => o,
Err(e) => {
return make(
IdeStatus::Skipped,
format!("not modified ({e}); fix the file or remove it and re-run"),
None,
);
}
};
let write_action = match outcome.action {
Action::Unchanged => {
return make(IdeStatus::Unchanged, "already up to date".into(), None);
}
Action::Created => WriteAction::Create,
Action::Updated => WriteAction::Update,
};
let status = IdeStatus::from_action(outcome.action);
if interactive {
let prompt = format!("→ {client} ({path}): {}", write_action.planned());
match confirm(&prompt) {
Ok(false) => return make(IdeStatus::Skipped, "declined by user".into(), None),
Err(e) => {
return make(IdeStatus::Error, format!("prompt failed: {e}"), None);
}
Ok(true) => {}
}
}
if dry_run {
return make(
status,
write_action.planned().into(),
Some(DiffPair {
before: existing,
after: outcome.new_json,
}),
);
}
if let Err(e) = atomic_write(&spec.path, &outcome.new_json) {
return make(IdeStatus::Error, format!("write failed: {e}"), None);
}
make(status, write_action.done().into(), None)
}
#[derive(Clone, Copy)]
enum WriteAction {
Create,
Update,
}
impl WriteAction {
fn planned(self) -> &'static str {
match self {
WriteAction::Create => "would create",
WriteAction::Update => "would update",
}
}
fn done(self) -> &'static str {
match self {
WriteAction::Create => "created",
WriteAction::Update => "updated",
}
}
}
fn atomic_write(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)
.with_context(|| format!("could not create {}", parent.display()))?;
}
}
let pid = std::process::id();
let file_name = path
.file_name()
.map(|s| s.to_os_string())
.unwrap_or_default();
let mut tmp_name = file_name;
tmp_name.push(format!(".cartog-{pid}.tmp"));
let tmp = path.with_file_name(tmp_name);
fs::write(&tmp, contents).with_context(|| format!("could not write {}", tmp.display()))?;
fs::rename(&tmp, path)
.with_context(|| format!("could not move tmp file into {}", path.display()))?;
Ok(())
}
fn client_name(kind: ClientKind) -> &'static str {
match kind {
ClientKind::ClaudeCode => "claude-code",
ClientKind::ClaudeDesktop => "claude-desktop",
ClientKind::Codex => "codex",
ClientKind::Cursor => "cursor",
ClientKind::Gemini => "gemini",
ClientKind::Opencode => "opencode",
ClientKind::Vscode => "vscode",
ClientKind::Windsurf => "windsurf",
ClientKind::Zed => "zed",
ClientKind::Antigravity => "antigravity",
ClientKind::Hermes => "hermes",
ClientKind::Kiro => "kiro",
}
}
fn confirm(label: &str) -> Result<bool> {
let mut stderr = std::io::stderr().lock();
write!(stderr, "{label}\n Apply? [Y/n] ")?;
stderr.flush()?;
drop(stderr);
let stdin = std::io::stdin();
let mut line = String::new();
let n = stdin.lock().read_line(&mut line)?;
if n == 0 {
return Ok(false);
}
let answer = line.trim().to_ascii_lowercase();
Ok(matches!(answer.as_str(), "" | "y" | "yes"))
}
#[cfg(test)]
mod tests {
use super::*;
fn args() -> Vec<String> {
vec!["serve".into()]
}
fn args_watch() -> Vec<String> {
vec!["serve".into(), "--watch".into()]
}
#[test]
fn merge_mcp_servers_empty_file_creates_entry() {
let o = merge_entry(None, MergeStrategy::McpServers, &args()).unwrap();
assert_eq!(o.action, Action::Created);
let v: Value = serde_json::from_str(&o.new_json).unwrap();
assert_eq!(v["mcpServers"]["cartog"]["command"], "cartog");
assert_eq!(v["mcpServers"]["cartog"]["args"], json!(["serve"]));
}
#[test]
fn merge_mcp_servers_preserves_other_servers() {
let existing = r#"{"mcpServers": {"other": {"command": "x", "args": ["a"]}}}"#;
let o = merge_entry(Some(existing), MergeStrategy::McpServers, &args()).unwrap();
assert_eq!(o.action, Action::Updated);
let v: Value = serde_json::from_str(&o.new_json).unwrap();
assert_eq!(v["mcpServers"]["other"]["command"], "x");
assert_eq!(v["mcpServers"]["cartog"]["command"], "cartog");
}
#[test]
fn merge_mcp_servers_idempotent() {
let first = merge_entry(None, MergeStrategy::McpServers, &args()).unwrap();
let second =
merge_entry(Some(&first.new_json), MergeStrategy::McpServers, &args()).unwrap();
assert_eq!(second.action, Action::Unchanged);
assert_eq!(first.new_json, second.new_json);
}
#[test]
fn merge_mcp_servers_updates_when_args_change() {
let first = merge_entry(None, MergeStrategy::McpServers, &args()).unwrap();
let second = merge_entry(
Some(&first.new_json),
MergeStrategy::McpServers,
&args_watch(),
)
.unwrap();
assert_eq!(second.action, Action::Updated);
let v: Value = serde_json::from_str(&second.new_json).unwrap();
assert_eq!(
v["mcpServers"]["cartog"]["args"],
json!(["serve", "--watch"])
);
}
#[test]
fn merge_opencode_strategy_uses_mcp_local_command_array() {
let o = merge_entry(None, MergeStrategy::Mcp, &args()).unwrap();
let v: Value = serde_json::from_str(&o.new_json).unwrap();
assert_eq!(v["mcp"]["cartog"]["type"], "local");
assert_eq!(v["mcp"]["cartog"]["enabled"], true);
assert_eq!(v["mcp"]["cartog"]["command"], json!(["cartog", "serve"]));
}
#[test]
fn merge_zed_strategy_uses_context_servers_with_flat_command() {
let o = merge_entry(None, MergeStrategy::ContextServers, &args()).unwrap();
let v: Value = serde_json::from_str(&o.new_json).unwrap();
assert_eq!(v["context_servers"]["cartog"]["command"], "cartog");
assert_eq!(v["context_servers"]["cartog"]["args"], json!(["serve"]));
}
#[test]
fn merge_invalid_json_returns_parse_error() {
let err = merge_entry(Some("{not json"), MergeStrategy::McpServers, &args()).unwrap_err();
assert!(err.to_string().contains("valid JSON"));
}
#[test]
fn merge_refuses_when_top_level_key_is_string() {
let existing = r#"{"mcpServers": "/etc/mcp/something.json"}"#;
let err = merge_entry(Some(existing), MergeStrategy::McpServers, &args()).unwrap_err();
assert!(
err.to_string().contains("refusing to overwrite"),
"expected refusal: {err}"
);
}
#[test]
fn merge_refuses_when_top_level_key_is_array() {
let existing = r#"{"context_servers": ["one", "two"]}"#;
let err = merge_entry(Some(existing), MergeStrategy::ContextServers, &args()).unwrap_err();
assert!(err.to_string().contains("refusing to overwrite"));
}
#[test]
fn merge_treats_null_top_level_key_as_absent() {
let existing = r#"{"servers": null}"#;
let outcome = merge_entry(Some(existing), MergeStrategy::VsCodeServers, &args()).unwrap();
let v: Value = serde_json::from_str(&outcome.new_json).unwrap();
assert_eq!(v["servers"]["cartog"]["command"], "cartog");
}
#[test]
fn merge_preserves_user_key_order() {
let existing = r#"{
"mcpServers": {
"zzz": {"command": "z"},
"aaa": {"command": "a"}
}
}"#;
let outcome = merge_entry(Some(existing), MergeStrategy::McpServers, &args()).unwrap();
let zzz_pos = outcome.new_json.find("\"zzz\"").unwrap();
let aaa_pos = outcome.new_json.find("\"aaa\"").unwrap();
assert!(
zzz_pos < aaa_pos,
"expected zzz before aaa (user-defined order), got:\n{}",
outcome.new_json
);
}
#[test]
fn merge_codex_toml_refuses_when_section_is_not_a_table() {
let existing = "[mcp_servers]\ncartog-x = \"oops\"\n";
let err = merge_codex_toml(Some(existing), &args(), "cartog-x").unwrap_err();
assert!(err.to_string().contains("refusing to overwrite"));
}
#[test]
fn merge_codex_toml_refuses_when_mcp_servers_is_not_a_table() {
let existing = "mcp_servers = \"not a table\"\n";
let err = merge_codex_toml(Some(existing), &args(), "cartog-x").unwrap_err();
assert!(err.to_string().contains("refusing to overwrite"));
}
#[test]
fn merge_vscode_strategy_uses_servers_key_and_stdio_type() {
let o = merge_entry(None, MergeStrategy::VsCodeServers, &args()).unwrap();
let v: Value = serde_json::from_str(&o.new_json).unwrap();
assert_eq!(v["servers"]["cartog"]["type"], "stdio");
assert_eq!(v["servers"]["cartog"]["command"], "cartog");
assert!(v.get("mcpServers").is_none(), "must not write mcpServers");
}
#[test]
fn merge_codex_toml_creates_section_under_mcp_servers() {
let outcome = merge_codex_toml(None, &args(), "cartog-myproj-deadbeef").unwrap();
assert_eq!(outcome.action, Action::Created);
assert!(outcome
.new_json
.contains("[mcp_servers.cartog-myproj-deadbeef]"));
assert!(outcome.new_json.contains("command = \"cartog\""));
assert!(outcome.new_json.contains("args = [\"serve\"]"));
}
#[test]
fn merge_codex_toml_preserves_other_servers_and_comments() {
let existing = "# user-managed file\n\
[mcp_servers.other]\n\
command = \"other\"\n\
args = [\"--flag\"]\n";
let outcome = merge_codex_toml(Some(existing), &args(), "cartog-x").unwrap();
assert_eq!(outcome.action, Action::Updated);
assert!(outcome.new_json.contains("# user-managed file"));
assert!(outcome.new_json.contains("[mcp_servers.other]"));
assert!(outcome.new_json.contains("[mcp_servers.cartog-x]"));
}
#[test]
fn merge_codex_toml_idempotent_with_same_section() {
let first = merge_codex_toml(None, &args(), "cartog-x").unwrap();
let second = merge_codex_toml(Some(&first.new_json), &args(), "cartog-x").unwrap();
assert_eq!(second.action, Action::Unchanged);
}
#[test]
fn merge_codex_toml_rejects_invalid_toml() {
let err = merge_codex_toml(Some("[not [valid toml"), &args(), "cartog-x").unwrap_err();
assert!(err.to_string().contains("valid TOML"));
}
#[test]
fn codex_section_name_is_deterministic_and_slug_safe() {
let p = std::env::temp_dir();
let a = codex_section_name(&p);
let b = codex_section_name(&p);
assert_eq!(a, b, "section name must be stable for the same dir");
assert!(a.starts_with("cartog-"));
assert!(a.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
}
#[test]
fn merge_empty_string_treated_as_empty_object() {
let o = merge_entry(Some(""), MergeStrategy::McpServers, &args()).unwrap();
assert_eq!(o.action, Action::Updated);
}
#[test]
fn build_specs_default_covers_all_clients() {
let tmp = std::env::temp_dir();
let homes = HomeDirs::detect();
let specs = build_specs(None, IdeScope::All, false, &tmp, &homes);
assert_eq!(specs.len(), 15);
}
#[test]
fn build_specs_project_scope_drops_user_clients() {
let tmp = std::env::temp_dir();
let homes = HomeDirs::default();
let specs = build_specs(None, IdeScope::Project, false, &tmp, &homes);
assert_eq!(specs.len(), 4);
assert!(specs.iter().all(|s| s.scope == Scope::Project));
}
#[test]
fn build_specs_claude_code_filter_returns_both_scopes() {
let tmp = std::env::temp_dir();
let homes = HomeDirs::default();
let specs = build_specs(
Some(ClientKind::ClaudeCode),
IdeScope::All,
false,
&tmp,
&homes,
);
assert_eq!(specs.len(), 2);
let scopes: Vec<_> = specs.iter().map(|s| s.scope).collect();
assert!(scopes.contains(&Scope::Project));
assert!(scopes.contains(&Scope::User));
}
#[test]
fn build_specs_vscode_filter_returns_project_and_user() {
let tmp = std::env::temp_dir();
let homes = HomeDirs {
vscode: tmp.join("Code/User/mcp.json"),
..HomeDirs::default()
};
let specs = build_specs(
Some(ClientKind::Vscode),
IdeScope::All,
false,
tmp.as_path(),
&homes,
);
let scopes: Vec<_> = specs.iter().map(|s| s.scope).collect();
assert!(
scopes.contains(&Scope::Project),
"vscode missing project scope"
);
assert!(scopes.contains(&Scope::User), "vscode missing user scope");
let user = specs
.iter()
.find(|s| s.scope == Scope::User)
.expect("vscode user spec");
assert!(user.path.ends_with("Code/User/mcp.json"));
assert_eq!(user.strategy, MergeStrategy::VsCodeServers);
}
#[test]
fn detect_vscode_user_path_under_config_dir() {
let homes = HomeDirs::detect();
assert!(
homes.vscode.ends_with("Code/User/mcp.json"),
"unexpected vscode user path: {}",
homes.vscode.display()
);
}
#[test]
fn build_specs_client_filter_picks_one() {
let tmp = std::env::temp_dir();
let homes = HomeDirs::default();
let specs = build_specs(Some(ClientKind::Cursor), IdeScope::All, false, &tmp, &homes);
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].kind, ClientKind::Cursor);
}
#[test]
fn install_filter_single_client_returns_only_that_kind() {
let chosen = filter_catalogue_by_clients(&[ClientKind::Cursor], IdeScope::All);
assert_eq!(chosen.len(), 1);
assert_eq!(chosen[0].0, ClientKind::Cursor);
}
#[test]
fn install_filter_multiple_clients_returns_each_in_catalogue_order() {
let chosen = filter_catalogue_by_clients(
&[ClientKind::Cursor, ClientKind::Vscode, ClientKind::Codex],
IdeScope::All,
);
let kinds: Vec<_> = chosen.iter().map(|(k, _)| *k).collect();
assert!(kinds.contains(&ClientKind::Cursor));
assert!(kinds.contains(&ClientKind::Vscode));
assert!(kinds.contains(&ClientKind::Codex));
assert_eq!(chosen.len(), 4);
}
#[test]
fn install_filter_claude_code_returns_both_project_and_user_scopes() {
let chosen = filter_catalogue_by_clients(&[ClientKind::ClaudeCode], IdeScope::All);
assert_eq!(chosen.len(), 2);
let scopes: Vec<_> = chosen.iter().map(|(_, s)| *s).collect();
assert!(scopes.contains(&Scope::Project));
assert!(scopes.contains(&Scope::User));
}
#[test]
fn install_filter_respects_project_scope() {
let chosen = filter_catalogue_by_clients(
&[ClientKind::Cursor, ClientKind::Codex],
IdeScope::Project,
);
assert_eq!(chosen.len(), 1);
assert_eq!(chosen[0].0, ClientKind::Cursor);
assert_eq!(chosen[0].1, Scope::Project);
}
#[test]
fn install_filter_empty_clients_returns_empty() {
let chosen = filter_catalogue_by_clients(&[], IdeScope::All);
assert!(chosen.is_empty());
}
#[test]
fn dedupe_drops_repeats_and_reports_them() {
let (unique, dropped) = dedupe_preserving_order(vec![
ClientKind::Cursor,
ClientKind::Vscode,
ClientKind::Cursor,
ClientKind::Cursor,
ClientKind::Codex,
]);
assert_eq!(
unique,
vec![ClientKind::Cursor, ClientKind::Vscode, ClientKind::Codex]
);
assert_eq!(dropped, vec![ClientKind::Cursor, ClientKind::Cursor]);
}
#[test]
fn dedupe_preserves_first_occurrence_order() {
let (unique, dropped) = dedupe_preserving_order(vec![
ClientKind::Vscode,
ClientKind::Cursor,
ClientKind::Vscode,
]);
assert_eq!(unique, vec![ClientKind::Vscode, ClientKind::Cursor]);
assert_eq!(dropped, vec![ClientKind::Vscode]);
}
#[test]
fn dedupe_empty_input_returns_two_empty_vecs() {
let (unique, dropped) = dedupe_preserving_order(Vec::new());
assert!(unique.is_empty());
assert!(dropped.is_empty());
}
#[test]
fn install_filter_user_only_client_at_project_scope_yields_empty() {
let chosen = filter_catalogue_by_clients(&[ClientKind::Codex], IdeScope::Project);
assert!(
chosen.is_empty(),
"codex has no Project entry in the catalogue"
);
}
#[test]
fn claude_code_args_include_watch_unless_no_watch() {
let tmp = std::env::temp_dir();
let homes = HomeDirs::default();
let with = build_specs(
Some(ClientKind::ClaudeCode),
IdeScope::Project,
false,
&tmp,
&homes,
);
let without = build_specs(
Some(ClientKind::ClaudeCode),
IdeScope::Project,
true,
&tmp,
&homes,
);
assert_eq!(with[0].args, vec!["serve", "--watch"]);
assert_eq!(without[0].args, vec!["serve"]);
}
fn opt(scope: Scope, installed: bool, file_present: bool) -> ScopeOption {
ScopeOption {
scope,
path: PathBuf::from("/tmp/foo"),
installed,
file_present,
}
}
#[test]
fn scope_option_status_reports_not_installed_when_parent_missing() {
assert_eq!(
scope_option_status(&opt(Scope::User, false, false)),
"not installed"
);
}
#[test]
fn scope_option_status_reports_will_create_when_parent_exists_but_file_does_not() {
assert_eq!(
scope_option_status(&opt(Scope::Project, true, false)),
"will create"
);
}
#[test]
fn scope_option_status_reports_will_merge_when_file_exists() {
assert_eq!(
scope_option_status(&opt(Scope::User, true, true)),
"present, will merge"
);
}
#[test]
fn picker_items_groups_claude_code_into_two_scopes() {
let tmp = tempfile::tempdir().unwrap();
let homes = HomeDirs::default();
let items = picker_items(tmp.path(), &homes);
let cc = items
.iter()
.find(|i| i.kind == ClientKind::ClaudeCode)
.unwrap();
assert_eq!(cc.scopes.len(), 2, "Claude Code should have 2 scopes");
let scopes: Vec<Scope> = cc.scopes.iter().map(|s| s.scope).collect();
assert!(scopes.contains(&Scope::Project));
assert!(scopes.contains(&Scope::User));
}
#[test]
fn picker_items_other_clients_have_a_single_scope() {
let tmp = tempfile::tempdir().unwrap();
let homes = HomeDirs::default();
let items = picker_items(tmp.path(), &homes);
let dual_scope = [ClientKind::ClaudeCode, ClientKind::Kiro, ClientKind::Vscode];
for item in &items {
if !dual_scope.contains(&item.kind) {
assert_eq!(
item.scopes.len(),
1,
"{:?} should have exactly one scope, got {}",
item.kind,
item.scopes.len(),
);
}
}
}
#[test]
fn picker_items_marks_project_clients_installed() {
let tmp = tempfile::tempdir().unwrap();
let homes = HomeDirs::default();
let items = picker_items(tmp.path(), &homes);
let cursor = items.iter().find(|i| i.kind == ClientKind::Cursor).unwrap();
assert!(cursor.scopes[0].installed);
assert!(!cursor.scopes[0].file_present);
}
#[test]
fn picker_items_marks_no_cli_user_client_not_installed_when_parent_missing() {
let tmp = tempfile::tempdir().unwrap();
let homes = HomeDirs {
claude_desktop: tmp.path().join("does/not/exist/desktop.json"),
..HomeDirs::default()
};
let items = picker_items(tmp.path(), &homes);
let desktop = items
.iter()
.find(|i| i.kind == ClientKind::ClaudeDesktop)
.unwrap();
assert!(
!desktop.scopes[0].installed,
"Claude Desktop with no config dir should be not-installed"
);
}
#[test]
fn binary_in_finds_executable_on_path() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("bin");
std::fs::create_dir(&dir).unwrap();
let exe = if cfg!(windows) {
"mytool.exe"
} else {
"mytool"
};
std::fs::write(dir.join(exe), b"#!/bin/sh\n").unwrap();
let paths = std::env::join_paths([dir.as_path()]).unwrap();
assert!(binary_in(&paths, "mytool"), "executable on PATH is found");
assert!(
!binary_in(&paths, "absent"),
"missing executable is not found"
);
}
#[test]
fn client_installed_is_always_true_for_project_scope() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("nope/mcp.json");
assert!(client_installed(
ClientKind::Cursor,
Scope::Project,
&missing,
None
));
}
#[test]
fn client_installed_no_cli_client_uses_dir_proxy() {
let tmp = tempfile::tempdir().unwrap();
let present = tmp.path().join("cfg.json"); let absent = tmp.path().join("nope/cfg.json"); assert!(client_installed(
ClientKind::ClaudeDesktop,
Scope::User,
&present,
None
));
assert!(!client_installed(
ClientKind::ClaudeDesktop,
Scope::User,
&absent,
None
));
}
#[test]
fn client_installed_detects_cli_only_via_injected_path() {
let tmp = tempfile::tempdir().unwrap();
let bindir = tmp.path().join("bin");
std::fs::create_dir(&bindir).unwrap();
let exe = if cfg!(windows) { "codex.exe" } else { "codex" };
std::fs::write(bindir.join(exe), b"#!/bin/sh\n").unwrap();
let with_codex = std::env::join_paths([bindir.as_path()]).unwrap();
let empty = std::env::join_paths::<_, &Path>([]).unwrap();
let cfg = tmp.path().join("nope/config.toml");
assert!(
client_installed(ClientKind::Codex, Scope::User, &cfg, Some(&with_codex)),
"codex on the injected PATH counts as installed"
);
assert!(
!client_installed(ClientKind::Codex, Scope::User, &cfg, Some(&empty)),
"no codex on PATH and no config dir → not installed"
);
}
#[test]
fn client_installed_ignores_path_when_home_is_relative_fallback() {
let tmp = tempfile::tempdir().unwrap();
let bindir = tmp.path().join("bin");
std::fs::create_dir(&bindir).unwrap();
let exe = if cfg!(windows) { "codex.exe" } else { "codex" };
std::fs::write(bindir.join(exe), b"#!/bin/sh\n").unwrap();
let with_codex = std::env::join_paths([bindir.as_path()]).unwrap();
let relative_cfg = Path::new("config.toml");
assert!(
!client_installed(
ClientKind::Codex,
Scope::User,
relative_cfg,
Some(&with_codex)
),
"PATH match must be ignored when the config path is relative (no real home)"
);
}
#[test]
fn picker_items_marks_user_client_installed_when_parent_exists() {
let tmp = tempfile::tempdir().unwrap();
let claude_dir = tmp.path().join("Library/Application Support/Claude");
std::fs::create_dir_all(&claude_dir).unwrap();
let homes = HomeDirs {
claude_desktop: claude_dir.join("claude_desktop_config.json"),
..HomeDirs::default()
};
let items = picker_items(tmp.path(), &homes);
let claude = items
.iter()
.find(|i| i.kind == ClientKind::ClaudeDesktop)
.unwrap();
assert!(claude.scopes[0].installed);
assert!(!claude.scopes[0].file_present);
}
#[test]
fn format_picker_label_single_scope_includes_name_and_status() {
let item = PickerItem {
kind: ClientKind::Cursor,
scopes: vec![opt(Scope::Project, true, false)],
};
let label = format_picker_label(&item);
assert!(label.contains("Cursor"), "label missing name: {label}");
assert!(
label.contains("project"),
"label missing scope hint: {label}"
);
assert!(
label.contains("will create"),
"label missing status: {label}"
);
}
#[test]
fn format_picker_label_multi_scope_hints_at_choice() {
let item = PickerItem {
kind: ClientKind::ClaudeCode,
scopes: vec![
opt(Scope::Project, true, false),
opt(Scope::User, true, true),
],
};
let label = format_picker_label(&item);
assert!(label.contains("Claude Code"));
assert!(
label.contains("project + user available"),
"multi-scope hint missing: {label}",
);
}
#[test]
fn any_installed_true_when_at_least_one_scope_installed() {
let item = PickerItem {
kind: ClientKind::ClaudeCode,
scopes: vec![
opt(Scope::Project, true, false),
opt(Scope::User, false, false),
],
};
assert!(item.any_installed());
}
#[test]
fn any_installed_false_when_no_scope_installed() {
let item = PickerItem {
kind: ClientKind::Zed,
scopes: vec![opt(Scope::User, false, false)],
};
assert!(!item.any_installed());
}
#[test]
fn spec_for_returns_none_for_unknown_combination() {
let tmp = tempfile::tempdir().unwrap();
let homes = HomeDirs::default();
assert!(spec_for(ClientKind::Cursor, Scope::User, false, tmp.path(), &homes).is_none());
}
#[test]
fn spec_for_builds_claude_code_with_or_without_watch() {
let tmp = tempfile::tempdir().unwrap();
let homes = HomeDirs::default();
let with = spec_for(
ClientKind::ClaudeCode,
Scope::Project,
false,
tmp.path(),
&homes,
)
.unwrap();
let without = spec_for(
ClientKind::ClaudeCode,
Scope::Project,
true,
tmp.path(),
&homes,
)
.unwrap();
assert_eq!(with.args, vec!["serve", "--watch"]);
assert_eq!(without.args, vec!["serve"]);
}
fn project_spec(path: PathBuf) -> ClientSpec {
ClientSpec {
kind: ClientKind::Cursor,
scope: Scope::Project,
path,
strategy: MergeStrategy::McpServers,
args: vec!["serve".into()],
}
}
#[test]
fn process_spec_creates_config_file() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("mcp.json");
let spec = project_spec(cfg.clone());
let step = process_spec(&spec, tmp.path(), false, false, false);
assert_eq!(step.status, IdeStatus::Created);
assert!(cfg.exists(), "config file written to disk");
let written = fs::read_to_string(&cfg).unwrap();
assert!(written.contains("mcpServers"), "wrote the mcpServers entry");
assert!(written.contains("cartog"), "wrote the cartog server");
}
#[test]
fn process_spec_unchanged_on_second_run() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("mcp.json");
let spec = project_spec(cfg);
let first = process_spec(&spec, tmp.path(), false, false, false);
assert_eq!(first.status, IdeStatus::Created);
let second = process_spec(&spec, tmp.path(), false, false, false);
assert_eq!(
second.status,
IdeStatus::Unchanged,
"re-running with identical config is a no-op"
);
}
#[test]
fn process_spec_dry_run_returns_diff_without_writing() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("mcp.json");
let spec = project_spec(cfg.clone());
let step = process_spec(&spec, tmp.path(), false, true, false);
assert_eq!(step.status, IdeStatus::Created);
assert!(step.diff.is_some(), "dry-run carries a before/after diff");
assert!(!cfg.exists(), "dry-run must not write the file");
}
#[test]
fn process_spec_skips_undetected_user_client_in_auto_mode() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("no-such-dir").join("config.json");
let mut spec = project_spec(cfg.clone());
spec.scope = Scope::User;
let step = process_spec(&spec, tmp.path(), false, false, true);
assert_eq!(step.status, IdeStatus::Skipped);
assert!(!cfg.exists(), "nothing written for a not-installed client");
}
#[test]
fn process_spec_wires_undetected_user_client_when_explicit() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("brand-new-dir").join("config.json");
assert!(!cfg.parent().unwrap().exists(), "parent dir starts absent");
let mut spec = project_spec(cfg.clone());
spec.scope = Scope::User;
let step = process_spec(&spec, tmp.path(), false, false, false);
assert_eq!(step.status, IdeStatus::Created);
assert!(cfg.exists(), "explicitly-requested client is wired");
assert!(
cfg.parent().unwrap().exists(),
"config dir created on demand"
);
}
#[test]
fn process_spec_skips_when_existing_file_is_malformed() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("mcp.json");
fs::write(&cfg, "{ this is not valid json").unwrap();
let spec = project_spec(cfg.clone());
let step = process_spec(&spec, tmp.path(), false, false, false);
assert_eq!(
step.status,
IdeStatus::Skipped,
"a malformed file is left untouched, not overwritten"
);
assert_eq!(
fs::read_to_string(&cfg).unwrap(),
"{ this is not valid json",
"original content preserved"
);
}
#[test]
fn build_specs_kiro_filter_returns_both_scopes() {
let tmp = std::env::temp_dir();
let homes = HomeDirs::default();
let specs = build_specs(Some(ClientKind::Kiro), IdeScope::All, false, &tmp, &homes);
assert_eq!(specs.len(), 2);
let scopes: Vec<_> = specs.iter().map(|s| s.scope).collect();
assert!(scopes.contains(&Scope::Project));
assert!(scopes.contains(&Scope::User));
}
#[test]
fn kiro_project_path_is_kiro_settings_mcp_json() {
let cwd = Path::new("/tmp/proj");
assert_eq!(
project_path(ClientKind::Kiro, cwd),
Some(cwd.join(".kiro").join("settings").join("mcp.json"))
);
}
#[test]
fn antigravity_and_hermes_are_user_only() {
let cwd = Path::new("/tmp/proj");
assert_eq!(project_path(ClientKind::Antigravity, cwd), None);
assert_eq!(project_path(ClientKind::Hermes, cwd), None);
}
#[test]
fn antigravity_and_kiro_use_mcp_servers_shape() {
let o = merge_entry(None, MergeStrategy::McpServers, &args()).unwrap();
let v: Value = serde_json::from_str(&o.new_json).unwrap();
assert_eq!(v["mcpServers"]["cartog"]["command"], "cartog");
}
#[test]
fn merge_hermes_yaml_empty_file_creates_entry() {
let o = merge_entry(None, MergeStrategy::HermesYaml, &args()).unwrap();
assert_eq!(o.action, Action::Created);
assert!(o.new_json.ends_with('\n'), "must terminate with a newline");
let v: serde_norway::Value = serde_norway::from_str(&o.new_json).unwrap();
assert_eq!(
v["mcp_servers"]["cartog"]["command"].as_str(),
Some("cartog")
);
assert_eq!(
v["mcp_servers"]["cartog"]["args"][0].as_str(),
Some("serve")
);
}
#[test]
fn merge_hermes_yaml_preserves_other_servers_and_keys() {
let existing = "model: hermes-4\n\
mcp_servers:\n \
filesystem:\n \
command: npx\n \
args: [\"-y\", \"server-filesystem\"]\n";
let o = merge_entry(Some(existing), MergeStrategy::HermesYaml, &args()).unwrap();
assert_eq!(o.action, Action::Updated);
let v: serde_norway::Value = serde_norway::from_str(&o.new_json).unwrap();
assert_eq!(v["model"].as_str(), Some("hermes-4"));
assert_eq!(
v["mcp_servers"]["filesystem"]["command"].as_str(),
Some("npx")
);
assert_eq!(
v["mcp_servers"]["cartog"]["command"].as_str(),
Some("cartog")
);
}
#[test]
fn merge_hermes_yaml_idempotent() {
let first = merge_entry(None, MergeStrategy::HermesYaml, &args()).unwrap();
let second =
merge_entry(Some(&first.new_json), MergeStrategy::HermesYaml, &args()).unwrap();
assert_eq!(second.action, Action::Unchanged);
assert_eq!(first.new_json, second.new_json);
}
#[test]
fn merge_hermes_yaml_refuses_when_mcp_servers_is_not_a_mapping() {
let existing = "mcp_servers: not-a-mapping\n";
let err = merge_entry(Some(existing), MergeStrategy::HermesYaml, &args()).unwrap_err();
assert!(
err.to_string().contains("refusing to overwrite"),
"expected refusal: {err}"
);
}
#[test]
fn merge_hermes_yaml_rejects_invalid_yaml() {
let err =
merge_entry(Some("key: [unclosed"), MergeStrategy::HermesYaml, &args()).unwrap_err();
assert!(err.to_string().contains("valid YAML"), "got: {err}");
}
}