1use std::fs;
13use std::path::{Path, PathBuf};
14
15use anyhow::{bail, Context, Result};
16use serde::{Deserialize, Serialize};
17
18use crate::harness::HarnessId;
19
20pub fn default_harnesses() -> Vec<HarnessId> {
24 vec![HarnessId::ClaudeCode]
25}
26
27pub(crate) const DEPRECATION_WARNING: &str = "\
31warning: .grove/explore.json is deprecated and will be removed in a future \
32version of grove. Your configuration has been automatically migrated to \
33.grove/config.json. Please commit the new file and remove \
34.grove/explore.json from your repository.";
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
42#[serde(rename_all = "kebab-case")]
43pub enum Mode {
44 Mcp,
46 Skill,
48 Both,
50 McpLlm,
52 Grammars,
54}
55
56impl Mode {
57 pub const LEGAL: &'static [&'static str] = &["mcp", "skill", "both", "mcp-llm", "grammars"];
59
60 pub fn from_name(s: &str) -> Result<Self> {
63 match s {
64 "mcp" => Ok(Mode::Mcp),
65 "skill" => Ok(Mode::Skill),
66 "both" => Ok(Mode::Both),
67 "mcp-llm" => Ok(Mode::McpLlm),
68 "grammars" => Ok(Mode::Grammars),
69 other => bail!(
70 "invalid `mode` value `{other}`: expected one of {}",
71 Self::LEGAL.join(", ")
72 ),
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize)]
82pub struct GroveConfig {
83 pub version: u32,
86 pub mode: Mode,
88 #[serde(skip_serializing_if = "Option::is_none")]
93 pub explore: Option<serde_json::Value>,
94 #[serde(default = "default_harnesses")]
97 pub harnesses: Vec<HarnessId>,
98}
99
100impl Default for GroveConfig {
101 fn default() -> Self {
102 GroveConfig {
103 version: 1,
104 mode: Mode::Mcp,
105 explore: None,
106 harnesses: default_harnesses(),
107 }
108 }
109}
110
111#[derive(Deserialize)]
115struct RawGroveConfig {
116 version: u32,
117 mode: String,
118 #[serde(default)]
119 explore: Option<serde_json::Value>,
120 #[serde(default = "default_harnesses")]
121 harnesses: Vec<HarnessId>,
122}
123
124impl TryFrom<RawGroveConfig> for GroveConfig {
125 type Error = anyhow::Error;
126
127 fn try_from(raw: RawGroveConfig) -> Result<Self> {
128 if raw.version != 1 {
129 bail!(
130 "`version` must be 1 (found {}); migrate via `grove upgrade`",
131 raw.version
132 );
133 }
134 Ok(GroveConfig {
135 version: raw.version,
136 mode: Mode::from_name(&raw.mode)?,
137 explore: raw.explore,
138 harnesses: raw.harnesses,
139 })
140 }
141}
142
143fn migrate_from_legacy_explore(root: &Path) -> Result<GroveConfig> {
156 let path = root.join(".grove").join("explore.json");
157 let text = fs::read_to_string(&path)
158 .with_context(|| format!("reading legacy explore config {}", path.display()))?;
159 let mut explore_val: serde_json::Value = serde_json::from_str(&text)
160 .with_context(|| format!("{} is not a valid legacy explore config", path.display()))?;
161 if let Some(obj) = explore_val.as_object_mut() {
164 if let Some(steering) = obj.remove("mode") {
165 obj.entry("steering").or_insert(steering);
166 }
167 }
168 let config = GroveConfig {
169 version: 1,
170 mode: Mode::McpLlm,
171 explore: Some(explore_val),
172 harnesses: default_harnesses(),
173 };
174 config.validate()?;
175 config.save(root)?;
176 eprintln!("{DEPRECATION_WARNING}");
177 Ok(config)
178}
179
180impl<'de> serde::Deserialize<'de> for GroveConfig {
181 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
182 where
183 D: serde::Deserializer<'de>,
184 {
185 let raw = RawGroveConfig::deserialize(deserializer)?;
186 GroveConfig::try_from(raw).map_err(serde::de::Error::custom)
187 }
188}
189
190impl GroveConfig {
191 pub fn config_path(root: &Path) -> PathBuf {
194 root.join(".grove").join("config.json")
195 }
196
197 pub fn load(root: &Path) -> Result<Self> {
206 let path = Self::config_path(root);
207 if path.exists() {
208 let text = fs::read_to_string(&path)
209 .with_context(|| format!("reading {}", path.display()))?;
210 let cfg: GroveConfig = serde_json::from_str(&text)
211 .with_context(|| format!("{} is not a valid grove config", path.display()))?;
212 cfg.validate()?;
213 Ok(cfg)
214 } else if root.join(".grove").join("explore.json").exists() {
215 migrate_from_legacy_explore(root)
216 } else {
217 bail!(
218 "no grove config at {} — run `grove init` to create one, \
219 or `grove config` to set it up",
220 path.display()
221 )
222 }
223 }
224
225 pub fn save(&self, root: &Path) -> Result<()> {
230 self.validate()?;
231 let dir = root.join(".grove");
232 fs::create_dir_all(&dir)
233 .with_context(|| format!("creating {}", dir.display()))?;
234 let path = dir.join("config.json");
235 let tmp = dir.join(format!("config.json.tmp.{}", std::process::id()));
236 let body = format!("{}\n", serde_json::to_string_pretty(self)?);
237 fs::write(&tmp, body).with_context(|| format!("writing {}", tmp.display()))?;
238 fs::rename(&tmp, &path)
239 .with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
240 Ok(())
241 }
242
243 pub fn validate(&self) -> Result<()> {
246 if self.version != 1 {
247 bail!(
248 "`version` must be 1 (found {}); migrate via `grove upgrade`",
249 self.version
250 );
251 }
252 Ok(())
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum ModeChoice {
263 None,
265}
266
267pub fn active_mode(root: &Path, force: ModeChoice) -> Mode {
280 match force {
281 ModeChoice::None => match GroveConfig::load(root) {
282 Ok(cfg) => cfg.mode,
283 Err(e) => {
284 eprintln!(
285 "grove: could not load config ({e}); \
286 defaulting to standard structural surface"
287 );
288 Mode::Mcp
289 }
290 },
291 }
292}
293
294#[cfg(test)]
299mod tests {
300 use super::*;
301
302 fn temp_root(tag: &str) -> PathBuf {
304 std::env::temp_dir().join(format!("grove_cfg_{}_{tag}", std::process::id()))
305 }
306
307 #[test]
309 fn serde_round_trip_each_mode() {
310 for &name in Mode::LEGAL {
311 let mode = Mode::from_name(name).unwrap();
312 let cfg = GroveConfig { version: 1, mode, explore: None, harnesses: default_harnesses() };
313 let json = serde_json::to_string(&cfg).unwrap();
314 let back: GroveConfig = serde_json::from_str(&json).unwrap();
315 assert_eq!(cfg, back, "round-trip failed for mode={name}");
316 }
317 }
318
319 #[test]
321 fn explore_section_absent_when_none() {
322 let cfg = GroveConfig::default();
323 let v = serde_json::to_value(&cfg).unwrap();
324 assert!(
325 v.get("explore").is_none(),
326 "explore key must be absent when None: {v}"
327 );
328 }
329
330 #[test]
334 fn explore_section_present_when_some() {
335 let explore_val = serde_json::json!({
336 "provider": "ollama",
337 "base_url": "http://localhost:11434/v1",
338 "model": "qwen2.5-coder:7b",
339 "steering": "standard",
340 "allowed_tools": ["grove"],
341 "tap": false,
342 "trace_retain": 50
343 });
344 let cfg = GroveConfig {
345 version: 1,
346 mode: Mode::McpLlm,
347 explore: Some(explore_val.clone()),
348 harnesses: default_harnesses(),
349 };
350 let json = serde_json::to_string(&cfg).unwrap();
351 let back: GroveConfig = serde_json::from_str(&json).unwrap();
352 assert_eq!(cfg, back);
353 let back_explore = back.explore.unwrap();
354 assert_eq!(back_explore["provider"], serde_json::json!("ollama"));
355 assert_eq!(back_explore["steering"], serde_json::json!("standard"));
356 assert_eq!(back_explore["model"], serde_json::json!("qwen2.5-coder:7b"));
357 }
358
359 #[test]
361 fn bad_mode_error_names_field_and_legal_values() {
362 let json = r#"{"version":1,"mode":"unknown"}"#;
363 let err = serde_json::from_str::<GroveConfig>(json).unwrap_err();
364 let msg = err.to_string();
365 assert!(msg.contains("mode"), "should name the field: {msg}");
366 for legal in Mode::LEGAL {
367 assert!(msg.contains(legal), "should list legal value {legal}: {msg}");
368 }
369 }
370
371 #[test]
374 fn missing_harnesses_defaults_to_claude_code() {
375 let json = r#"{"version":1,"mode":"mcp"}"#;
376 let cfg: GroveConfig = serde_json::from_str(json).unwrap();
377 assert_eq!(cfg.harnesses, vec![HarnessId::ClaudeCode]);
378 }
379
380 #[test]
383 fn explicit_harnesses_round_trip_by_slug() {
384 let json = r#"{"version":1,"mode":"mcp","harnesses":["claude-code","cursor","codex","vscode"]}"#;
385 let cfg: GroveConfig = serde_json::from_str(json).unwrap();
386 assert_eq!(
387 cfg.harnesses,
388 vec![HarnessId::ClaudeCode, HarnessId::Cursor, HarnessId::Codex, HarnessId::VsCode]
389 );
390 let out = serde_json::to_string(&cfg).unwrap();
392 assert!(out.contains(r#""vscode""#), "serializes VsCode as `vscode`: {out}");
393 assert!(!out.contains("vs-code"), "must not use derived kebab spelling: {out}");
394 }
395
396 #[test]
398 fn unknown_harness_slug_is_actionable_error() {
399 let json = r#"{"version":1,"mode":"mcp","harnesses":["emacs"]}"#;
400 let err = serde_json::from_str::<GroveConfig>(json).unwrap_err().to_string();
401 assert!(err.contains("emacs"), "names the bad value: {err}");
402 assert!(err.contains("cursor"), "lists legal values: {err}");
403 }
404
405 #[test]
408 fn steering_key_in_explore_section() {
409 let json = r#"{
410 "version": 1,
411 "mode": "mcp-llm",
412 "explore": {
413 "provider": "ollama",
414 "base_url": "http://localhost:11434/v1",
415 "model": "x",
416 "steering": "balanced",
417 "allowed_tools": ["grove"]
418 }
419 }"#;
420 let cfg: GroveConfig = serde_json::from_str(json).unwrap();
421 let explore = cfg.explore.expect("explore section should be present");
422 assert_eq!(explore["steering"], serde_json::json!("balanced"));
423 }
424
425 #[test]
427 fn save_load_round_trip_atomic() {
428 let root = temp_root("save_load");
429 let _ = fs::remove_dir_all(&root);
430 let cfg = GroveConfig::default();
431
432 cfg.save(&root).unwrap();
433
434 let path = GroveConfig::config_path(&root);
435 assert!(path.exists(), "config.json should exist after save");
436
437 let dir = root.join(".grove");
439 let leftovers: Vec<_> = fs::read_dir(&dir)
440 .unwrap()
441 .filter_map(|e| e.ok())
442 .map(|e| e.file_name().to_string_lossy().into_owned())
443 .filter(|n| n.contains(".tmp."))
444 .collect();
445 assert!(leftovers.is_empty(), "temp file leaked: {leftovers:?}");
446
447 let loaded = GroveConfig::load(&root).unwrap();
448 assert_eq!(cfg, loaded);
449
450 fs::remove_dir_all(&root).unwrap();
451 }
452
453 #[test]
455 fn missing_file_actionable_error() {
456 let root = temp_root("missing");
457 let err = GroveConfig::load(&root).unwrap_err();
458 let msg = format!("{err:#}");
459 assert!(
460 msg.contains("grove init") || msg.contains("grove config"),
461 "error should steer user to setup: {msg}"
462 );
463 }
464
465 #[test]
467 fn bad_version_rejected() {
468 let json = r#"{"version":2,"mode":"mcp"}"#;
469 let err = serde_json::from_str::<GroveConfig>(json).unwrap_err();
470 let msg = err.to_string();
471 assert!(msg.contains("version"), "should name the field: {msg}");
472 }
473
474 #[test]
478 fn migrate_legacy_explore_writes_config_json() {
479 let root = temp_root("legacy_migrate");
480 let _ = fs::remove_dir_all(&root);
481 let grove_dir = root.join(".grove");
482 fs::create_dir_all(&grove_dir).unwrap();
483
484 let legacy = r#"{
486 "provider": "ollama",
487 "base_url": "http://localhost:11434/v1",
488 "model": "qwen2.5-coder:7b",
489 "mode": "balanced",
490 "allowed_tools": ["grove"],
491 "tap": false,
492 "trace_retain": 50
493 }"#;
494 fs::write(grove_dir.join("explore.json"), legacy).unwrap();
495
496 let cfg = GroveConfig::load(&root).unwrap();
498
499 assert_eq!(cfg.mode, Mode::McpLlm, "mode should be McpLlm after migration");
502 let explore = cfg.explore.as_ref().expect("explore section must be present");
503 assert_eq!(
504 explore["steering"],
505 serde_json::json!("balanced"),
506 "steering should be 'balanced' (mapped from legacy mode=balanced)"
507 );
508 assert!(explore.get("mode").is_none(), "legacy `mode` key must be removed");
509
510 let config_path = GroveConfig::config_path(&root);
512 assert!(config_path.exists(), "config.json should exist after migration");
513
514 fs::remove_dir_all(&root).unwrap();
515 }
516
517 #[test]
521 fn second_load_after_migration_reads_config_not_legacy() {
522 let root = temp_root("legacy_second_load");
523 let _ = fs::remove_dir_all(&root);
524 let grove_dir = root.join(".grove");
525 fs::create_dir_all(&grove_dir).unwrap();
526
527 let legacy = r#"{
528 "provider": "ollama",
529 "base_url": "http://localhost:11434/v1",
530 "model": "qwen2.5-coder:7b",
531 "mode": "standard",
532 "allowed_tools": ["grove"]
533 }"#;
534 let legacy_path = grove_dir.join("explore.json");
535 fs::write(&legacy_path, legacy).unwrap();
536
537 let cfg1 = GroveConfig::load(&root).unwrap();
539
540 let mtime_before = fs::metadata(&legacy_path).unwrap().modified().unwrap();
542
543 let cfg2 = GroveConfig::load(&root).unwrap();
545 assert_eq!(cfg1, cfg2, "second load must return an equal config");
546
547 let mtime_after = fs::metadata(&legacy_path).unwrap().modified().unwrap();
549 assert_eq!(mtime_before, mtime_after, "explore.json must not be modified by the second load");
550
551 fs::remove_dir_all(&root).unwrap();
552 }
553
554 #[test]
558 fn config_json_present_ignores_stale_explore_json() {
559 let root = temp_root("stale_explore");
560 let _ = fs::remove_dir_all(&root);
561 let grove_dir = root.join(".grove");
562 fs::create_dir_all(&grove_dir).unwrap();
563
564 let cfg_json = r#"{"version":1,"mode":"mcp"}"#;
566 fs::write(grove_dir.join("config.json"), cfg_json).unwrap();
567
568 let stale = r#"{
570 "provider": "ollama",
571 "base_url": "http://localhost:11434/v1",
572 "model": "old-model",
573 "mode": "aggressive",
574 "allowed_tools": []
575 }"#;
576 fs::write(grove_dir.join("explore.json"), stale).unwrap();
577
578 let cfg = GroveConfig::load(&root).unwrap();
580 assert_eq!(cfg.mode, Mode::Mcp, "should read config.json, not migrate from explore.json");
581 assert!(cfg.explore.is_none(), "explore section should not be populated from stale file");
582
583 fs::remove_dir_all(&root).unwrap();
584 }
585
586 #[test]
592 fn active_mode_none_reads_declared_mcp_mode() {
593 let root = temp_root("am_mcp");
594 let _ = fs::remove_dir_all(&root);
595 let grove_dir = root.join(".grove");
596 fs::create_dir_all(&grove_dir).unwrap();
597 let cfg_json = r#"{"version":1,"mode":"mcp"}"#;
598 fs::write(grove_dir.join("config.json"), cfg_json).unwrap();
599 assert_eq!(active_mode(&root, ModeChoice::None), Mode::Mcp);
600 let _ = fs::remove_dir_all(&root);
601 }
602
603 #[test]
605 fn active_mode_none_reads_declared_mcp_llm_mode() {
606 let root = temp_root("am_mcpllm");
607 let _ = fs::remove_dir_all(&root);
608 let grove_dir = root.join(".grove");
609 fs::create_dir_all(&grove_dir).unwrap();
610 let cfg_json = r#"{"version":1,"mode":"mcp-llm","explore":{"provider":"ollama","base_url":"http://localhost:11434/v1","model":"x","steering":"standard","allowed_tools":[]}}"#;
611 fs::write(grove_dir.join("config.json"), cfg_json).unwrap();
612 assert_eq!(active_mode(&root, ModeChoice::None), Mode::McpLlm);
613 let _ = fs::remove_dir_all(&root);
614 }
615
616 #[test]
620 fn active_mode_mcp_config_ignores_stale_explore_json() {
621 let root = temp_root("am_stale");
622 let _ = fs::remove_dir_all(&root);
623 let grove_dir = root.join(".grove");
624 fs::create_dir_all(&grove_dir).unwrap();
625 fs::write(grove_dir.join("config.json"), r#"{"version":1,"mode":"mcp"}"#).unwrap();
627 let stale = r#"{"provider":"ollama","base_url":"http://localhost:11434/v1","model":"old","mode":"aggressive","allowed_tools":[]}"#;
629 fs::write(grove_dir.join("explore.json"), stale).unwrap();
630 assert_eq!(active_mode(&root, ModeChoice::None), Mode::Mcp,
632 "stale explore.json must not override declared mode=mcp in config.json");
633 let _ = fs::remove_dir_all(&root);
634 }
635
636 #[test]
638 fn active_mode_no_config_falls_back_to_mcp() {
639 let root = temp_root("am_noconfig");
640 let _ = fs::remove_dir_all(&root);
641 assert_eq!(active_mode(&root, ModeChoice::None), Mode::Mcp,
643 "missing config must fall back gracefully to Mcp");
644 let _ = fs::remove_dir_all(&root);
645 }
646
647 #[test]
655 fn deprecation_warning_emitted() {
656 assert!(
658 DEPRECATION_WARNING.contains("explore.json"),
659 "warning should mention explore.json: {DEPRECATION_WARNING}"
660 );
661 assert!(
662 DEPRECATION_WARNING.contains("config.json"),
663 "warning should mention config.json: {DEPRECATION_WARNING}"
664 );
665 assert!(
666 DEPRECATION_WARNING.contains("deprecated"),
667 "warning should contain the word 'deprecated': {DEPRECATION_WARNING}"
668 );
669 assert!(
670 DEPRECATION_WARNING.contains("migrated"),
671 "warning should mention migration: {DEPRECATION_WARNING}"
672 );
673
674 let root = temp_root("warn_emitted");
677 let _ = fs::remove_dir_all(&root);
678 let grove_dir = root.join(".grove");
679 fs::create_dir_all(&grove_dir).unwrap();
680 let legacy = r#"{
681 "provider": "ollama",
682 "base_url": "http://localhost:11434/v1",
683 "model": "x",
684 "mode": "standard",
685 "allowed_tools": []
686 }"#;
687 fs::write(grove_dir.join("explore.json"), legacy).unwrap();
688 GroveConfig::load(&root).unwrap();
690 assert!(
692 GroveConfig::config_path(&root).exists(),
693 "config.json must exist after migration (proves warning path ran)"
694 );
695 fs::remove_dir_all(&root).unwrap();
696 }
697}