use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::harness::HarnessId;
pub fn default_harnesses() -> Vec<HarnessId> {
vec![HarnessId::ClaudeCode]
}
pub(crate) const DEPRECATION_WARNING: &str = "\
warning: .grove/explore.json is deprecated and will be removed in a future \
version of grove. Your configuration has been automatically migrated to \
.grove/config.json. Please commit the new file and remove \
.grove/explore.json from your repository.";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Mode {
Mcp,
Skill,
Both,
McpLlm,
Grammars,
}
impl Mode {
pub const LEGAL: &'static [&'static str] = &["mcp", "skill", "both", "mcp-llm", "grammars"];
pub fn from_name(s: &str) -> Result<Self> {
match s {
"mcp" => Ok(Mode::Mcp),
"skill" => Ok(Mode::Skill),
"both" => Ok(Mode::Both),
"mcp-llm" => Ok(Mode::McpLlm),
"grammars" => Ok(Mode::Grammars),
other => bail!(
"invalid `mode` value `{other}`: expected one of {}",
Self::LEGAL.join(", ")
),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GroveConfig {
pub version: u32,
pub mode: Mode,
#[serde(skip_serializing_if = "Option::is_none")]
pub explore: Option<serde_json::Value>,
#[serde(default = "default_harnesses")]
pub harnesses: Vec<HarnessId>,
}
impl Default for GroveConfig {
fn default() -> Self {
GroveConfig {
version: 1,
mode: Mode::Mcp,
explore: None,
harnesses: default_harnesses(),
}
}
}
#[derive(Deserialize)]
struct RawGroveConfig {
version: u32,
mode: String,
#[serde(default)]
explore: Option<serde_json::Value>,
#[serde(default = "default_harnesses")]
harnesses: Vec<HarnessId>,
}
impl TryFrom<RawGroveConfig> for GroveConfig {
type Error = anyhow::Error;
fn try_from(raw: RawGroveConfig) -> Result<Self> {
if raw.version != 1 {
bail!(
"`version` must be 1 (found {}); migrate via `grove upgrade`",
raw.version
);
}
Ok(GroveConfig {
version: raw.version,
mode: Mode::from_name(&raw.mode)?,
explore: raw.explore,
harnesses: raw.harnesses,
})
}
}
fn migrate_from_legacy_explore(root: &Path) -> Result<GroveConfig> {
let path = root.join(".grove").join("explore.json");
let text = fs::read_to_string(&path)
.with_context(|| format!("reading legacy explore config {}", path.display()))?;
let mut explore_val: serde_json::Value = serde_json::from_str(&text)
.with_context(|| format!("{} is not a valid legacy explore config", path.display()))?;
if let Some(obj) = explore_val.as_object_mut() {
if let Some(steering) = obj.remove("mode") {
obj.entry("steering").or_insert(steering);
}
}
let config = GroveConfig {
version: 1,
mode: Mode::McpLlm,
explore: Some(explore_val),
harnesses: default_harnesses(),
};
config.validate()?;
config.save(root)?;
eprintln!("{DEPRECATION_WARNING}");
Ok(config)
}
impl<'de> serde::Deserialize<'de> for GroveConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = RawGroveConfig::deserialize(deserializer)?;
GroveConfig::try_from(raw).map_err(serde::de::Error::custom)
}
}
impl GroveConfig {
pub fn config_path(root: &Path) -> PathBuf {
root.join(".grove").join("config.json")
}
pub fn load(root: &Path) -> Result<Self> {
let path = Self::config_path(root);
if path.exists() {
let text = fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let cfg: GroveConfig = serde_json::from_str(&text)
.with_context(|| format!("{} is not a valid grove config", path.display()))?;
cfg.validate()?;
Ok(cfg)
} else if root.join(".grove").join("explore.json").exists() {
migrate_from_legacy_explore(root)
} else {
bail!(
"no grove config at {} — run `grove init` to create one, \
or `grove config` to set it up",
path.display()
)
}
}
pub fn save(&self, root: &Path) -> Result<()> {
self.validate()?;
let dir = root.join(".grove");
fs::create_dir_all(&dir)
.with_context(|| format!("creating {}", dir.display()))?;
let path = dir.join("config.json");
let tmp = dir.join(format!("config.json.tmp.{}", std::process::id()));
let body = format!("{}\n", serde_json::to_string_pretty(self)?);
fs::write(&tmp, body).with_context(|| format!("writing {}", tmp.display()))?;
fs::rename(&tmp, &path)
.with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
pub fn validate(&self) -> Result<()> {
if self.version != 1 {
bail!(
"`version` must be 1 (found {}); migrate via `grove upgrade`",
self.version
);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModeChoice {
None,
}
pub fn active_mode(root: &Path, force: ModeChoice) -> Mode {
match force {
ModeChoice::None => match GroveConfig::load(root) {
Ok(cfg) => cfg.mode,
Err(e) => {
eprintln!(
"grove: could not load config ({e}); \
defaulting to standard structural surface"
);
Mode::Mcp
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("grove_cfg_{}_{tag}", std::process::id()))
}
#[test]
fn serde_round_trip_each_mode() {
for &name in Mode::LEGAL {
let mode = Mode::from_name(name).unwrap();
let cfg = GroveConfig { version: 1, mode, explore: None, harnesses: default_harnesses() };
let json = serde_json::to_string(&cfg).unwrap();
let back: GroveConfig = serde_json::from_str(&json).unwrap();
assert_eq!(cfg, back, "round-trip failed for mode={name}");
}
}
#[test]
fn explore_section_absent_when_none() {
let cfg = GroveConfig::default();
let v = serde_json::to_value(&cfg).unwrap();
assert!(
v.get("explore").is_none(),
"explore key must be absent when None: {v}"
);
}
#[test]
fn explore_section_present_when_some() {
let explore_val = serde_json::json!({
"provider": "ollama",
"base_url": "http://localhost:11434/v1",
"model": "qwen2.5-coder:7b",
"steering": "standard",
"allowed_tools": ["grove"],
"tap": false,
"trace_retain": 50
});
let cfg = GroveConfig {
version: 1,
mode: Mode::McpLlm,
explore: Some(explore_val.clone()),
harnesses: default_harnesses(),
};
let json = serde_json::to_string(&cfg).unwrap();
let back: GroveConfig = serde_json::from_str(&json).unwrap();
assert_eq!(cfg, back);
let back_explore = back.explore.unwrap();
assert_eq!(back_explore["provider"], serde_json::json!("ollama"));
assert_eq!(back_explore["steering"], serde_json::json!("standard"));
assert_eq!(back_explore["model"], serde_json::json!("qwen2.5-coder:7b"));
}
#[test]
fn bad_mode_error_names_field_and_legal_values() {
let json = r#"{"version":1,"mode":"unknown"}"#;
let err = serde_json::from_str::<GroveConfig>(json).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("mode"), "should name the field: {msg}");
for legal in Mode::LEGAL {
assert!(msg.contains(legal), "should list legal value {legal}: {msg}");
}
}
#[test]
fn missing_harnesses_defaults_to_claude_code() {
let json = r#"{"version":1,"mode":"mcp"}"#;
let cfg: GroveConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.harnesses, vec![HarnessId::ClaudeCode]);
}
#[test]
fn explicit_harnesses_round_trip_by_slug() {
let json = r#"{"version":1,"mode":"mcp","harnesses":["claude-code","cursor","codex","vscode"]}"#;
let cfg: GroveConfig = serde_json::from_str(json).unwrap();
assert_eq!(
cfg.harnesses,
vec![HarnessId::ClaudeCode, HarnessId::Cursor, HarnessId::Codex, HarnessId::VsCode]
);
let out = serde_json::to_string(&cfg).unwrap();
assert!(out.contains(r#""vscode""#), "serializes VsCode as `vscode`: {out}");
assert!(!out.contains("vs-code"), "must not use derived kebab spelling: {out}");
}
#[test]
fn unknown_harness_slug_is_actionable_error() {
let json = r#"{"version":1,"mode":"mcp","harnesses":["emacs"]}"#;
let err = serde_json::from_str::<GroveConfig>(json).unwrap_err().to_string();
assert!(err.contains("emacs"), "names the bad value: {err}");
assert!(err.contains("cursor"), "lists legal values: {err}");
}
#[test]
fn steering_key_in_explore_section() {
let json = r#"{
"version": 1,
"mode": "mcp-llm",
"explore": {
"provider": "ollama",
"base_url": "http://localhost:11434/v1",
"model": "x",
"steering": "balanced",
"allowed_tools": ["grove"]
}
}"#;
let cfg: GroveConfig = serde_json::from_str(json).unwrap();
let explore = cfg.explore.expect("explore section should be present");
assert_eq!(explore["steering"], serde_json::json!("balanced"));
}
#[test]
fn save_load_round_trip_atomic() {
let root = temp_root("save_load");
let _ = fs::remove_dir_all(&root);
let cfg = GroveConfig::default();
cfg.save(&root).unwrap();
let path = GroveConfig::config_path(&root);
assert!(path.exists(), "config.json should exist after save");
let dir = root.join(".grove");
let leftovers: Vec<_> = fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".tmp."))
.collect();
assert!(leftovers.is_empty(), "temp file leaked: {leftovers:?}");
let loaded = GroveConfig::load(&root).unwrap();
assert_eq!(cfg, loaded);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn missing_file_actionable_error() {
let root = temp_root("missing");
let err = GroveConfig::load(&root).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("grove init") || msg.contains("grove config"),
"error should steer user to setup: {msg}"
);
}
#[test]
fn bad_version_rejected() {
let json = r#"{"version":2,"mode":"mcp"}"#;
let err = serde_json::from_str::<GroveConfig>(json).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("version"), "should name the field: {msg}");
}
#[test]
fn migrate_legacy_explore_writes_config_json() {
let root = temp_root("legacy_migrate");
let _ = fs::remove_dir_all(&root);
let grove_dir = root.join(".grove");
fs::create_dir_all(&grove_dir).unwrap();
let legacy = r#"{
"provider": "ollama",
"base_url": "http://localhost:11434/v1",
"model": "qwen2.5-coder:7b",
"mode": "balanced",
"allowed_tools": ["grove"],
"tap": false,
"trace_retain": 50
}"#;
fs::write(grove_dir.join("explore.json"), legacy).unwrap();
let cfg = GroveConfig::load(&root).unwrap();
assert_eq!(cfg.mode, Mode::McpLlm, "mode should be McpLlm after migration");
let explore = cfg.explore.as_ref().expect("explore section must be present");
assert_eq!(
explore["steering"],
serde_json::json!("balanced"),
"steering should be 'balanced' (mapped from legacy mode=balanced)"
);
assert!(explore.get("mode").is_none(), "legacy `mode` key must be removed");
let config_path = GroveConfig::config_path(&root);
assert!(config_path.exists(), "config.json should exist after migration");
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn second_load_after_migration_reads_config_not_legacy() {
let root = temp_root("legacy_second_load");
let _ = fs::remove_dir_all(&root);
let grove_dir = root.join(".grove");
fs::create_dir_all(&grove_dir).unwrap();
let legacy = r#"{
"provider": "ollama",
"base_url": "http://localhost:11434/v1",
"model": "qwen2.5-coder:7b",
"mode": "standard",
"allowed_tools": ["grove"]
}"#;
let legacy_path = grove_dir.join("explore.json");
fs::write(&legacy_path, legacy).unwrap();
let cfg1 = GroveConfig::load(&root).unwrap();
let mtime_before = fs::metadata(&legacy_path).unwrap().modified().unwrap();
let cfg2 = GroveConfig::load(&root).unwrap();
assert_eq!(cfg1, cfg2, "second load must return an equal config");
let mtime_after = fs::metadata(&legacy_path).unwrap().modified().unwrap();
assert_eq!(mtime_before, mtime_after, "explore.json must not be modified by the second load");
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn config_json_present_ignores_stale_explore_json() {
let root = temp_root("stale_explore");
let _ = fs::remove_dir_all(&root);
let grove_dir = root.join(".grove");
fs::create_dir_all(&grove_dir).unwrap();
let cfg_json = r#"{"version":1,"mode":"mcp"}"#;
fs::write(grove_dir.join("config.json"), cfg_json).unwrap();
let stale = r#"{
"provider": "ollama",
"base_url": "http://localhost:11434/v1",
"model": "old-model",
"mode": "aggressive",
"allowed_tools": []
}"#;
fs::write(grove_dir.join("explore.json"), stale).unwrap();
let cfg = GroveConfig::load(&root).unwrap();
assert_eq!(cfg.mode, Mode::Mcp, "should read config.json, not migrate from explore.json");
assert!(cfg.explore.is_none(), "explore section should not be populated from stale file");
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn active_mode_none_reads_declared_mcp_mode() {
let root = temp_root("am_mcp");
let _ = fs::remove_dir_all(&root);
let grove_dir = root.join(".grove");
fs::create_dir_all(&grove_dir).unwrap();
let cfg_json = r#"{"version":1,"mode":"mcp"}"#;
fs::write(grove_dir.join("config.json"), cfg_json).unwrap();
assert_eq!(active_mode(&root, ModeChoice::None), Mode::Mcp);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn active_mode_none_reads_declared_mcp_llm_mode() {
let root = temp_root("am_mcpllm");
let _ = fs::remove_dir_all(&root);
let grove_dir = root.join(".grove");
fs::create_dir_all(&grove_dir).unwrap();
let cfg_json = r#"{"version":1,"mode":"mcp-llm","explore":{"provider":"ollama","base_url":"http://localhost:11434/v1","model":"x","steering":"standard","allowed_tools":[]}}"#;
fs::write(grove_dir.join("config.json"), cfg_json).unwrap();
assert_eq!(active_mode(&root, ModeChoice::None), Mode::McpLlm);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn active_mode_mcp_config_ignores_stale_explore_json() {
let root = temp_root("am_stale");
let _ = fs::remove_dir_all(&root);
let grove_dir = root.join(".grove");
fs::create_dir_all(&grove_dir).unwrap();
fs::write(grove_dir.join("config.json"), r#"{"version":1,"mode":"mcp"}"#).unwrap();
let stale = r#"{"provider":"ollama","base_url":"http://localhost:11434/v1","model":"old","mode":"aggressive","allowed_tools":[]}"#;
fs::write(grove_dir.join("explore.json"), stale).unwrap();
assert_eq!(active_mode(&root, ModeChoice::None), Mode::Mcp,
"stale explore.json must not override declared mode=mcp in config.json");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn active_mode_no_config_falls_back_to_mcp() {
let root = temp_root("am_noconfig");
let _ = fs::remove_dir_all(&root);
assert_eq!(active_mode(&root, ModeChoice::None), Mode::Mcp,
"missing config must fall back gracefully to Mcp");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn deprecation_warning_emitted() {
assert!(
DEPRECATION_WARNING.contains("explore.json"),
"warning should mention explore.json: {DEPRECATION_WARNING}"
);
assert!(
DEPRECATION_WARNING.contains("config.json"),
"warning should mention config.json: {DEPRECATION_WARNING}"
);
assert!(
DEPRECATION_WARNING.contains("deprecated"),
"warning should contain the word 'deprecated': {DEPRECATION_WARNING}"
);
assert!(
DEPRECATION_WARNING.contains("migrated"),
"warning should mention migration: {DEPRECATION_WARNING}"
);
let root = temp_root("warn_emitted");
let _ = fs::remove_dir_all(&root);
let grove_dir = root.join(".grove");
fs::create_dir_all(&grove_dir).unwrap();
let legacy = r#"{
"provider": "ollama",
"base_url": "http://localhost:11434/v1",
"model": "x",
"mode": "standard",
"allowed_tools": []
}"#;
fs::write(grove_dir.join("explore.json"), legacy).unwrap();
GroveConfig::load(&root).unwrap();
assert!(
GroveConfig::config_path(&root).exists(),
"config.json must exist after migration (proves warning path ran)"
);
fs::remove_dir_all(&root).unwrap();
}
}