use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
Ollama,
LlamaCpp,
}
impl Provider {
pub const LEGAL: &'static [&'static str] = &["ollama", "llamacpp"];
pub(crate) fn from_name(s: &str) -> Result<Self> {
match s {
"ollama" => Ok(Provider::Ollama),
"llamacpp" => Ok(Provider::LlamaCpp),
other => bail!(
"invalid `provider` value `{other}`: expected one of {}",
Self::LEGAL.join(", ")
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Steering {
Standard,
Balanced,
Strict,
}
impl Steering {
pub const LEGAL: &'static [&'static str] = &["standard", "balanced", "strict"];
pub(crate) fn from_name(s: &str) -> Result<Self> {
match s {
"standard" => Ok(Steering::Standard),
"balanced" => Ok(Steering::Balanced),
"strict" => Ok(Steering::Strict),
"aggressive" => Ok(Steering::Strict),
other => bail!(
"invalid `steering` value `{other}`: expected one of {}",
Self::LEGAL.join(", ")
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExploreConfig {
pub provider: Provider,
pub base_url: String,
pub model: String,
pub steering: Steering,
pub allowed_tools: Vec<String>,
#[serde(default)]
pub tap: bool,
#[serde(default = "default_trace_retain")]
pub trace_retain: u32,
}
pub const DEFAULT_TRACE_RETAIN: u32 = 50;
fn default_trace_retain() -> u32 {
DEFAULT_TRACE_RETAIN
}
#[derive(Deserialize)]
struct RawExploreConfig {
provider: String,
base_url: String,
model: String,
steering: String,
#[serde(default)]
allowed_tools: Vec<String>,
#[serde(default)]
tap: bool,
#[serde(default = "default_trace_retain")]
trace_retain: u32,
}
impl TryFrom<RawExploreConfig> for ExploreConfig {
type Error = anyhow::Error;
fn try_from(raw: RawExploreConfig) -> Result<Self> {
Ok(ExploreConfig {
provider: Provider::from_name(&raw.provider)?,
base_url: raw.base_url,
model: raw.model,
steering: Steering::from_name(&raw.steering)?,
allowed_tools: raw.allowed_tools,
tap: raw.tap,
trace_retain: raw.trace_retain,
})
}
}
impl<'de> Deserialize<'de> for ExploreConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = RawExploreConfig::deserialize(deserializer)?;
ExploreConfig::try_from(raw).map_err(serde::de::Error::custom)
}
}
impl Default for ExploreConfig {
fn default() -> Self {
ExploreConfig {
provider: Provider::Ollama,
base_url: "http://localhost:11434/v1".to_string(),
model: "qwen2.5-coder:7b".to_string(),
steering: Steering::Standard,
allowed_tools: vec![
"grove".to_string(),
"rg".to_string(),
"grep".to_string(),
"find".to_string(),
],
tap: false,
trace_retain: DEFAULT_TRACE_RETAIN,
}
}
}
impl ExploreConfig {
pub fn config_path(root: &Path) -> PathBuf {
root.join(".grove").join("explore.json")
}
pub fn load(root: &Path) -> Result<Self> {
let path = Self::config_path(root);
if !path.exists() {
bail!(
"no explore config at {} — run `grove init --as mcp-llm` to create one, \
or `grove config` to set it up",
path.display()
);
}
let text = fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let cfg: ExploreConfig = serde_json::from_str(&text)
.with_context(|| format!("{} is not a valid explore config", path.display()))?;
cfg.validate()?;
Ok(cfg)
}
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("explore.json");
let tmp = dir.join(format!("explore.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.base_url.trim().is_empty() {
bail!("`base_url` must not be empty (e.g. http://localhost:11434/v1)");
}
if self.model.trim().is_empty() {
bail!("`model` must not be empty (set a model identifier your provider serves)");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("grove_explore_cfg_{}_{tag}", std::process::id()))
}
#[test]
fn serde_round_trip_defaults() {
let cfg = ExploreConfig::default();
let json = serde_json::to_string(&cfg).unwrap();
let back: ExploreConfig = serde_json::from_str(&json).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn fixed_fixture_deserializes() {
let json = r#"{
"provider": "llamacpp",
"base_url": "http://localhost:8080/v1",
"model": "custom",
"steering": "strict",
"allowed_tools": ["grove"]
}"#;
let cfg: ExploreConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.provider, Provider::LlamaCpp);
assert_eq!(cfg.steering, Steering::Strict);
assert_eq!(cfg.base_url, "http://localhost:8080/v1");
assert_eq!(cfg.model, "custom");
assert_eq!(cfg.allowed_tools, vec!["grove".to_string()]);
}
#[test]
fn strict_serializes_lowercase_and_aggressive_is_a_read_alias() {
assert_eq!(serde_json::to_string(&Steering::Strict).unwrap(), "\"strict\"");
assert_eq!(Steering::from_name("strict").unwrap(), Steering::Strict);
assert_eq!(Steering::from_name("aggressive").unwrap(), Steering::Strict);
}
#[test]
fn defaults_are_ollama() {
let cfg = ExploreConfig::default();
assert_eq!(cfg.provider, Provider::Ollama);
assert_eq!(cfg.base_url, "http://localhost:11434/v1");
assert_eq!(cfg.model, "qwen2.5-coder:7b");
assert_eq!(cfg.steering, Steering::Standard);
assert_eq!(cfg.allowed_tools, vec!["grove", "rg", "grep", "find"]);
assert_eq!(cfg.trace_retain, DEFAULT_TRACE_RETAIN);
}
#[test]
fn trace_retain_defaults_when_absent() {
let json = r#"{
"provider": "ollama",
"base_url": "http://localhost:11434/v1",
"model": "x",
"steering": "standard",
"allowed_tools": ["grove"]
}"#;
let cfg: ExploreConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.trace_retain, DEFAULT_TRACE_RETAIN);
}
#[test]
fn provider_serializes_lowercase() {
assert_eq!(serde_json::to_string(&Provider::Ollama).unwrap(), "\"ollama\"");
assert_eq!(serde_json::to_string(&Provider::LlamaCpp).unwrap(), "\"llamacpp\"");
assert_eq!(serde_json::to_string(&Steering::Standard).unwrap(), "\"standard\"");
}
#[test]
fn missing_file_steers_to_init() {
let root = temp_root("missing");
let err = ExploreConfig::load(&root).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("grove init --as mcp-llm") && msg.contains("grove config"),
"message should steer to setup: {msg}"
);
}
#[test]
fn bad_enum_names_field_and_legal_values() {
let json = r#"{
"provider": "gpt4all",
"base_url": "http://localhost:11434/v1",
"model": "x",
"steering": "standard",
"allowed_tools": []
}"#;
let err = serde_json::from_str::<ExploreConfig>(json).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("provider"), "should name the field: {msg}");
assert!(msg.contains("ollama") && msg.contains("llamacpp"), "should list legal values: {msg}");
}
#[test]
fn empty_required_fields_rejected() {
let cfg = ExploreConfig { base_url: " ".to_string(), ..ExploreConfig::default() };
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("base_url"), "{err}");
let cfg = ExploreConfig { model: String::new(), ..ExploreConfig::default() };
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("model"), "{err}");
}
#[test]
fn save_then_load_round_trips_atomically() {
let root = temp_root("save_load");
let _ = fs::remove_dir_all(&root);
let cfg = ExploreConfig::default();
cfg.save(&root).unwrap();
let path = ExploreConfig::config_path(&root);
assert!(path.exists(), "explore.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 = ExploreConfig::load(&root).unwrap();
assert_eq!(cfg, loaded);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn legacy_mode_key_rejected() {
let json = r#"{
"provider": "ollama",
"base_url": "http://localhost:11434/v1",
"model": "x",
"mode": "standard",
"allowed_tools": []
}"#;
let err = serde_json::from_str::<ExploreConfig>(json).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("steering") || msg.contains("missing"), "should fail on missing steering: {msg}");
}
}