Skip to main content

grove_core/
config.rs

1//! Grove project configuration — `.grove/config.json`.
2//!
3//! [`GroveConfig`] is the top-level project configuration record. It specifies
4//! the integration [`Mode`] (which grove surface to activate) and optionally
5//! carries an [`ExploreConfig`] section for the mcp-llm explorer subsystem.
6//!
7//! Persistence is atomic (temp-file + rename) and fail-fast:
8//! - [`GroveConfig::load`] yields an actionable error when the file is absent.
9//! - [`GroveConfig::save`] creates `.grove/` on first write.
10//! - [`GroveConfig::validate`] rejects unknown versions and illegal mode values.
11
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use anyhow::{bail, Context, Result};
16use serde::{Deserialize, Serialize};
17
18use crate::harness::HarnessId;
19
20/// The default harness set for a project with no explicit `harnesses` array:
21/// Claude Code only. Keeps pre-multi-harness `config.json` files (which lack
22/// the field) behaving exactly as before.
23pub fn default_harnesses() -> Vec<HarnessId> {
24    vec![HarnessId::ClaudeCode]
25}
26
27/// Deprecation warning emitted once when `.grove/explore.json` is migrated to
28/// `.grove/config.json`. Kept as a named constant so tests can assert on its
29/// content without capturing stderr.
30pub(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/// The integration mode — which grove surface is active for a project.
37///
38/// On-disk spellings use kebab-case (e.g. `"mcp-llm"`). Use [`Mode::LEGAL`]
39/// and [`Mode::from_name`] for parse / enumeration. Serialized with
40/// `serde(rename_all = "kebab-case")`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
42#[serde(rename_all = "kebab-case")]
43pub enum Mode {
44    /// Standard MCP structural surface (outline, symbols, source, …).
45    Mcp,
46    /// Skill-server surface.
47    Skill,
48    /// Both MCP and skill surfaces active.
49    Both,
50    /// MCP + inner LLM explorer (the mcp-llm surface).
51    McpLlm,
52    /// Grammar-registry management surface.
53    Grammars,
54}
55
56impl Mode {
57    /// The legal on-disk spellings, in declaration order.
58    pub const LEGAL: &'static [&'static str] = &["mcp", "skill", "both", "mcp-llm", "grammars"];
59
60    /// Parse a mode from its on-disk spelling, yielding a descriptive error on
61    /// failure that names the field and lists legal values.
62    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/// The grove project configuration, persisted to `.grove/config.json`.
78///
79/// Construct with [`GroveConfig::default`] (mode = `mcp`, no explore section),
80/// then persist with [`GroveConfig::save`].
81#[derive(Debug, Clone, PartialEq, Serialize)]
82pub struct GroveConfig {
83    /// Wire version. The only valid value today is `1`; [`validate`] rejects
84    /// other values to ensure future migrations are explicit.
85    pub version: u32,
86    /// Which grove integration surface is active.
87    pub mode: Mode,
88    /// Optional mcp-llm explorer configuration, stored as a raw JSON value
89    /// (opaque to core). The CLI layer (which depends on grove-explore-core)
90    /// deserializes this into `ExploreConfig` when it needs typed access.
91    /// Omitted from the serialized form when `None` (see `skip_serializing_if`).
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub explore: Option<serde_json::Value>,
94    /// The coding agents `grove init` has wired into this project. Defaults to
95    /// `[claude-code]` when absent, so pre-multi-harness configs are unchanged.
96    #[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/// Raw wire shape: `mode` kept as `String` so parse failures can name the
112/// offending field and enumerate legal values — serde's stock unknown-variant
113/// error does neither.
114#[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
143/// Read `.grove/explore.json`, map its old wire shape to a full [`GroveConfig`]
144/// (mode = `McpLlm`, renaming the legacy `"mode"` key to `"steering"` in the raw
145/// JSON), persist `config.json` atomically, and emit a one-time deprecation
146/// warning to stderr.
147///
148/// This function is the only code path that reads `explore.json`.  It does not
149/// delete `explore.json` — removal is left to the user; `grove doctor` will
150/// warn about its presence.
151///
152/// Provider/Steering validation is deferred to the CLI layer, which deserializes
153/// the opaque `serde_json::Value` into `ExploreConfig` (from `grove-explore-core`)
154/// when typed access is needed.
155fn 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    // The old wire key for steering level was `"mode"`; rename it to `"steering"`
162    // so the migrated config.json matches the current schema.
163    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    /// The canonical config path for a project rooted at `root`:
192    /// `<root>/.grove/config.json`.
193    pub fn config_path(root: &Path) -> PathBuf {
194        root.join(".grove").join("config.json")
195    }
196
197    /// Read, deserialize, and validate the config under `root`.
198    ///
199    /// Three-branch cascade:
200    /// 1. `config.json` present → read, deserialize, validate (normal path).
201    /// 2. `explore.json` present → one-time legacy migration: synthesise a
202    ///    [`GroveConfig`] from the old wire shape, write `config.json`
203    ///    atomically, emit a deprecation warning, and return the config.
204    /// 3. Neither → actionable `grove init` error (unchanged).
205    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    /// Validate, then persist to `<root>/.grove/config.json` atomically.
226    ///
227    /// The write goes to a sibling temp file in the same directory and is then
228    /// `rename`d into place. Creates `.grove/` if absent.
229    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    /// Reject structurally invalid state:
244    /// - `version` must be `1` (the only supported wire version).
245    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/// Caller's preference for the integration mode.
257///
258/// Kept as a single-variant enum so existing callers (e.g. `core::doctor`
259/// and tests in `cli::init`) can continue to pass [`ModeChoice::None`]
260/// without a breaking API change. There are no CLI force flags any more.
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum ModeChoice {
263    /// Read the declared `mode` from the project config.
264    None,
265}
266
267/// Resolve the effective [`Mode`] for a project from its declared config.
268///
269/// The `force` parameter is retained for API compatibility; only
270/// [`ModeChoice::None`] is legal and it simply loads [`GroveConfig`] and
271/// returns `cfg.mode`. If loading fails (no `config.json` and no legacy
272/// `explore.json`) the function falls back to `Mode::Mcp` and emits a
273/// diagnostic on stderr.
274///
275/// This function performs no network I/O and no health probing — it is a
276/// pure config resolver. The legacy `explore.json` path is reached only via
277/// [`GroveConfig::load`]'s own cascade (migrate + write `config.json`);
278/// callers no longer sniff `explore.json` existence directly.
279pub 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// ---------------------------------------------------------------------------
295// Unit tests
296// ---------------------------------------------------------------------------
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    /// A unique, per-process temp project root; caller cleans up.
303    fn temp_root(tag: &str) -> PathBuf {
304        std::env::temp_dir().join(format!("grove_cfg_{}_{tag}", std::process::id()))
305    }
306
307    // T1 — round-trip each integration Mode variant.
308    #[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    // T2 — explore absent when None.
320    #[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    // T3 — explore section round-trips when Some.
331    // After the refactor, explore is opaque serde_json::Value — we verify the
332    // JSON round-trip preserves the known fields rather than comparing typed structs.
333    #[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    // T4 — bad mode names the field and lists legal values.
360    #[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    // T4b — a config.json without the `harnesses` field defaults to [claude-code]
372    // (backward compatibility with pre-multi-harness configs).
373    #[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    // T4c — an explicit harnesses array round-trips through serde by slug, and
381    // preserves order + membership.
382    #[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        // Re-serialize: the slugs (not derived kebab) must appear — `vscode`, not `vs-code`.
391        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    // T4d — an unknown harness slug is a descriptive error listing legal values.
397    #[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    // T5 — steering key in explore section deserializes correctly.
406    // After the refactor, explore is opaque Value — verify the raw JSON field.
407    #[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    // T6 — save + load round-trip; no leftover temp file.
426    #[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        // No leftover temp file.
438        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    // T7 — missing file gives actionable error.
454    #[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    // version != 1 rejected by validate().
466    #[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    // -----------------------------------------------------------------------
475    // T-5a: legacy explore.json (old `mode` key) migrates → config.json.
476    // -----------------------------------------------------------------------
477    #[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        // Write the legacy explore.json with old `mode` key ("balanced")
485        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        // Load should trigger migration.
497        let cfg = GroveConfig::load(&root).unwrap();
498
499        // Returned config: mode = McpLlm; explore is now an opaque Value with
500        // the legacy `mode` key renamed to `steering`.
501        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        // config.json must exist on disk after migration.
511        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    // -----------------------------------------------------------------------
518    // T-5b: second load reads config.json directly; explore.json unmodified.
519    // -----------------------------------------------------------------------
520    #[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        // First load triggers migration.
538        let cfg1 = GroveConfig::load(&root).unwrap();
539
540        // Record explore.json mtime before second load.
541        let mtime_before = fs::metadata(&legacy_path).unwrap().modified().unwrap();
542
543        // Second load reads config.json — no migration re-runs.
544        let cfg2 = GroveConfig::load(&root).unwrap();
545        assert_eq!(cfg1, cfg2, "second load must return an equal config");
546
547        // explore.json must not have been touched by the second load.
548        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    // -----------------------------------------------------------------------
555    // T-5c: config.json present alongside stale explore.json — stale ignored.
556    // -----------------------------------------------------------------------
557    #[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        // Write a proper config.json with mode=mcp.
565        let cfg_json = r#"{"version":1,"mode":"mcp"}"#;
566        fs::write(grove_dir.join("config.json"), cfg_json).unwrap();
567
568        // Write a stale (but parseable) explore.json alongside it.
569        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        // Load must use config.json, not explore.json.
579        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    // -----------------------------------------------------------------------
587    // active_mode tests
588    // -----------------------------------------------------------------------
589
590    // AM-1: None + config.json mode=mcp → Mcp.
591    #[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    // AM-2: None + config.json mode=mcp-llm → McpLlm.
604    #[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    // AM-3 (bug-1 regression): config.json mode=mcp + stale explore.json → Mcp.
617    // The old determine_surface sniffed explore.json existence; active_mode must
618    // not — the declared mode in config.json is the single source of truth.
619    #[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        // config.json declares mcp.
626        fs::write(grove_dir.join("config.json"), r#"{"version":1,"mode":"mcp"}"#).unwrap();
627        // stale explore.json sits alongside it.
628        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        // Must return Mcp (config.json wins; explore.json is ignored).
631        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    // AM-4: no config at all → falls back to Mcp (no panic, no error propagated).
637    #[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        // Neither config.json nor explore.json exist.
642        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    // -----------------------------------------------------------------------
648    // T-5d: deprecation warning is structurally correct.
649    // -----------------------------------------------------------------------
650    // We verify via the named DEPRECATION_WARNING const that the warning text
651    // is complete and contains the key information users need. The warning IS
652    // emitted to stderr during migration (tests 5a and 5b run the migration
653    // path and exercise the eprintln! call); here we assert content quality.
654    #[test]
655    fn deprecation_warning_emitted() {
656        // The const must reference both file paths so the message is actionable.
657        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        // Confirm the migration path is reached when only explore.json is present
675        // (side-effect of eprintln! being called — no stderr capture needed).
676        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        // migration runs → eprintln!(DEPRECATION_WARNING) is called.
689        GroveConfig::load(&root).unwrap();
690        // config.json written is our proof the warning branch was fully executed.
691        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}