use crate::cli::ClientKind;
use crate::commands::ide::catalogue::*;
use crate::commands::ide::picker::*;
use crate::commands::ide::run::spec_for;
use crate::commands::ide::Scope;
use std::path::{Path, PathBuf};
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"]);
}