use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use crate::config::Mode;
pub const GROVE_START: &str = "<!-- grove:start -->";
pub const GROVE_END: &str = "<!-- grove:end -->";
pub const MCP_SERVER_KEY: &str = "grove";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HarnessId {
ClaudeCode,
Cursor,
Codex,
Gemini,
Windsurf,
VsCode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Project,
Global,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpFormat {
Json {
root_key: &'static str,
needs_type_stdio: bool,
},
Toml { table: &'static str },
}
impl HarnessId {
pub const ALL: &'static [HarnessId] = &[
HarnessId::ClaudeCode,
HarnessId::Cursor,
HarnessId::Codex,
HarnessId::Gemini,
HarnessId::Windsurf,
HarnessId::VsCode,
];
pub fn slug(self) -> &'static str {
match self {
HarnessId::ClaudeCode => "claude-code",
HarnessId::Cursor => "cursor",
HarnessId::Codex => "codex",
HarnessId::Gemini => "gemini",
HarnessId::Windsurf => "windsurf",
HarnessId::VsCode => "vscode",
}
}
pub fn legal_slugs() -> Vec<&'static str> {
Self::ALL.iter().map(|h| h.slug()).collect()
}
pub fn from_slug(s: &str) -> Result<Self> {
Self::ALL
.iter()
.copied()
.find(|h| h.slug() == s)
.ok_or(())
.or_else(|()| {
bail!(
"invalid agent `{s}`: expected one of {}",
Self::legal_slugs().join(", ")
)
})
}
pub fn display_name(self) -> &'static str {
match self {
HarnessId::ClaudeCode => "Claude Code",
HarnessId::Cursor => "Cursor",
HarnessId::Codex => "Codex",
HarnessId::Gemini => "Gemini CLI",
HarnessId::Windsurf => "Windsurf",
HarnessId::VsCode => "VS Code",
}
}
pub fn mcp_scope(self) -> Scope {
match self {
HarnessId::Codex => Scope::Global,
_ => Scope::Project,
}
}
pub fn mcp_format(self) -> McpFormat {
match self {
HarnessId::Codex => McpFormat::Toml {
table: "mcp_servers",
},
HarnessId::VsCode => McpFormat::Json {
root_key: "servers",
needs_type_stdio: true,
},
_ => McpFormat::Json {
root_key: "mcpServers",
needs_type_stdio: false,
},
}
}
pub fn mcp_config_path(self, root: &Path) -> Result<PathBuf> {
let home = match self.mcp_scope() {
Scope::Global => {
Some(dirs::home_dir().ok_or_else(|| {
anyhow::anyhow!("cannot determine home directory for {} config", self.display_name())
})?)
}
Scope::Project => None,
};
Ok(self.mcp_config_path_in(root, home.as_deref()))
}
pub fn mcp_config_path_in(self, root: &Path, home: Option<&Path>) -> PathBuf {
match self {
HarnessId::ClaudeCode => root.join(".mcp.json"),
HarnessId::Cursor => root.join(".cursor").join("mcp.json"),
HarnessId::Gemini => root.join(".gemini").join("settings.json"),
HarnessId::Windsurf => root.join(".windsurf").join("mcp.json"),
HarnessId::VsCode => root.join(".vscode").join("mcp.json"),
HarnessId::Codex => home
.unwrap_or(root)
.join(".codex")
.join("config.toml"),
}
}
pub fn tool_prefix(self) -> &'static str {
match self {
HarnessId::ClaudeCode => "mcp__grove__",
_ => "",
}
}
pub fn detect_bins(self) -> &'static [&'static str] {
match self {
HarnessId::ClaudeCode => &["claude"],
HarnessId::Cursor => &["cursor"],
HarnessId::Codex => &["codex"],
HarnessId::Gemini => &["gemini"],
HarnessId::Windsurf => &["windsurf"],
HarnessId::VsCode => &["code"],
}
}
pub fn detect_marker(self, root: &Path) -> Option<PathBuf> {
match self {
HarnessId::Cursor => Some(root.join(".cursor")),
HarnessId::Gemini => Some(root.join(".gemini")),
HarnessId::Windsurf => Some(root.join(".windsurf")),
HarnessId::VsCode => Some(root.join(".vscode")),
HarnessId::ClaudeCode | HarnessId::Codex => None,
}
}
}
impl Serialize for HarnessId {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.slug())
}
}
impl<'de> Deserialize<'de> for HarnessId {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
HarnessId::from_slug(&s).map_err(serde::de::Error::custom)
}
}
pub fn expected_mcp_args(mode: Mode) -> Option<&'static [&'static str]> {
match mode {
Mode::Mcp | Mode::Both => Some(&["serve"]),
Mode::McpLlm => Some(&["serve", "--explore"]),
Mode::Skill | Mode::Grammars => None,
}
}
pub fn expected_claude_marker(mode: Mode) -> Option<&'static str> {
match mode {
Mode::McpLlm => Some("mcp__grove__explore"),
Mode::Mcp | Mode::Both => Some("mcp__grove__outline"),
Mode::Skill => Some("grove skill"),
Mode::Grammars => None,
}
}
pub fn agents_md_expected(mode: Mode) -> bool {
matches!(mode, Mode::Mcp | Mode::Both | Mode::McpLlm)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Mode;
#[test]
fn expected_mcp_args_coverage() {
assert_eq!(expected_mcp_args(Mode::Mcp), Some(["serve"].as_slice()));
assert_eq!(expected_mcp_args(Mode::Both), Some(["serve"].as_slice()));
assert_eq!(
expected_mcp_args(Mode::McpLlm),
Some(["serve", "--explore"].as_slice())
);
assert_eq!(expected_mcp_args(Mode::Skill), None);
assert_eq!(expected_mcp_args(Mode::Grammars), None);
}
#[test]
fn expected_claude_marker_coverage() {
assert_eq!(
expected_claude_marker(Mode::McpLlm),
Some("mcp__grove__explore")
);
assert_eq!(
expected_claude_marker(Mode::Mcp),
Some("mcp__grove__outline")
);
assert_eq!(
expected_claude_marker(Mode::Both),
Some("mcp__grove__outline")
);
assert_eq!(expected_claude_marker(Mode::Skill), Some("grove skill"));
assert_eq!(expected_claude_marker(Mode::Grammars), None);
}
#[test]
fn agents_md_expected_for_all_mcp_surfaces() {
assert!(agents_md_expected(Mode::McpLlm));
assert!(agents_md_expected(Mode::Mcp));
assert!(agents_md_expected(Mode::Both));
assert!(!agents_md_expected(Mode::Skill));
assert!(!agents_md_expected(Mode::Grammars));
}
#[test]
fn slug_round_trips_over_all() {
for &h in HarnessId::ALL {
assert_eq!(HarnessId::from_slug(h.slug()).unwrap(), h, "round-trip {h:?}");
}
let mut slugs: Vec<_> = HarnessId::ALL.iter().map(|h| h.slug()).collect();
slugs.sort_unstable();
slugs.dedup();
assert_eq!(slugs.len(), HarnessId::ALL.len(), "slugs must be unique");
}
#[test]
fn from_slug_bad_names_field_and_legal_values() {
let err = HarnessId::from_slug("emacs").unwrap_err().to_string();
assert!(err.contains("emacs"), "names the bad value: {err}");
for &h in HarnessId::ALL {
assert!(err.contains(h.slug()), "lists legal value {}: {err}", h.slug());
}
}
#[test]
fn vscode_uses_servers_root_key_and_type_stdio() {
match HarnessId::VsCode.mcp_format() {
McpFormat::Json { root_key, needs_type_stdio } => {
assert_eq!(root_key, "servers", "VS Code root key is `servers`, not `mcpServers`");
assert!(needs_type_stdio, "VS Code entries need type:stdio");
}
other => panic!("VS Code should be JSON, got {other:?}"),
}
}
#[test]
fn most_json_harnesses_use_mcp_servers() {
for &h in &[HarnessId::ClaudeCode, HarnessId::Cursor, HarnessId::Gemini, HarnessId::Windsurf] {
match h.mcp_format() {
McpFormat::Json { root_key, needs_type_stdio } => {
assert_eq!(root_key, "mcpServers", "{h:?} uses mcpServers");
assert!(!needs_type_stdio);
}
other => panic!("{h:?} should be JSON, got {other:?}"),
}
}
}
#[test]
fn codex_is_global_toml() {
assert_eq!(HarnessId::Codex.mcp_scope(), Scope::Global);
match HarnessId::Codex.mcp_format() {
McpFormat::Toml { table } => assert_eq!(table, "mcp_servers"),
other => panic!("Codex should be TOML, got {other:?}"),
}
}
#[test]
fn only_claude_code_has_mcp_tool_prefix() {
assert_eq!(HarnessId::ClaudeCode.tool_prefix(), "mcp__grove__");
for &h in HarnessId::ALL {
if h != HarnessId::ClaudeCode {
assert_eq!(h.tool_prefix(), "", "{h:?} steering uses bare tool names");
}
}
}
#[test]
fn project_paths_resolve_under_root_global_under_home() {
let root = Path::new("/proj");
let home = Path::new("/home/u");
assert_eq!(
HarnessId::ClaudeCode.mcp_config_path_in(root, Some(home)),
Path::new("/proj/.mcp.json")
);
assert_eq!(
HarnessId::VsCode.mcp_config_path_in(root, Some(home)),
Path::new("/proj/.vscode/mcp.json")
);
assert_eq!(
HarnessId::Cursor.mcp_config_path_in(root, Some(home)),
Path::new("/proj/.cursor/mcp.json")
);
assert_eq!(
HarnessId::Codex.mcp_config_path_in(root, Some(home)),
Path::new("/home/u/.codex/config.toml")
);
}
#[test]
fn detect_markers_present_only_for_project_footprint_harnesses() {
let root = Path::new("/proj");
assert_eq!(HarnessId::Cursor.detect_marker(root), Some(root.join(".cursor")));
assert_eq!(HarnessId::VsCode.detect_marker(root), Some(root.join(".vscode")));
assert!(HarnessId::ClaudeCode.detect_marker(root).is_none());
assert!(HarnessId::Codex.detect_marker(root).is_none());
}
}