use std::path::Path;
use crate::{
config::{active_mode, default_harnesses, GroveConfig, Mode, ModeChoice},
explore::{health_probe, ExploreConfig, HealthError},
harness::{self, HarnessId, McpFormat},
registry::{self, LockVerifyStatus},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Ok,
Warn,
Fail,
Info,
}
#[derive(Debug, Clone)]
pub struct Check {
pub group: &'static str,
pub name: &'static str,
pub status: Status,
pub detail: String,
pub hint: Option<String>,
}
#[derive(Debug)]
pub struct Report {
pub mode: Mode,
pub checks: Vec<Check>,
}
impl Report {
pub fn ok(&self) -> bool {
self.checks.iter().all(|c| !matches!(c.status, Status::Fail))
}
}
pub fn diagnose(root: &Path, force: ModeChoice) -> Report {
let mut checks: Vec<Check> = Vec::new();
checks.push(Check {
group: "universal",
name: "grove_version",
status: Status::Info,
detail: env!("CARGO_PKG_VERSION").to_string(),
hint: None,
});
let cfg_result = GroveConfig::load(root);
let declared_mode = match &cfg_result {
Ok(cfg) => cfg.mode,
Err(_) => Mode::Mcp,
};
let has_explore_cfg = cfg_result
.as_ref()
.ok()
.and_then(|c| c.explore.as_ref())
.is_some();
match &cfg_result {
Ok(cfg) => {
checks.push(Check {
group: "universal",
name: "config_present",
status: Status::Ok,
detail: format!(
"{} · mode={}",
GroveConfig::config_path(root).display(),
mode_name(cfg.mode)
),
hint: None,
});
}
Err(e) => {
if !GroveConfig::config_path(root).exists() {
checks.push(Check {
group: "universal",
name: "config_present",
status: Status::Info,
detail: "no .grove/config.json — project not yet initialized".to_string(),
hint: Some("grove init".to_string()),
});
} else {
checks.push(Check {
group: "universal",
name: "config_present",
status: Status::Fail,
detail: format!(
"could not load {}: {e}",
GroveConfig::config_path(root).display()
),
hint: Some("grove init --as <mode>".to_string()),
});
}
}
}
let legacy_path = root.join(".grove").join("explore.json");
let config_path = GroveConfig::config_path(root);
if legacy_path.exists() && !config_path.exists() {
checks.push(Check {
group: "universal",
name: "legacy_explore_json",
status: Status::Warn,
detail: ".grove/explore.json present without .grove/config.json — \
run `grove init` to migrate"
.to_string(),
hint: Some("grove init --as mcp-llm".to_string()),
});
}
let mode = active_mode(root, force);
let harnesses = cfg_result
.as_ref()
.ok()
.map(|c| c.harnesses.clone())
.unwrap_or_else(default_harnesses);
let home = dirs::home_dir().unwrap_or_else(|| root.to_path_buf());
let claude_selected = harnesses.contains(&HarnessId::ClaudeCode);
if claude_selected {
checks.push(check_harness_mcp_json(root, mode));
}
for &h in &harnesses {
if h != HarnessId::ClaudeCode {
checks.push(check_harness_registration(root, &home, h, mode));
}
}
checks.push(check_harness_claude_md(root, mode, claude_selected));
checks.push(check_harness_agents_md(root, mode));
checks.push(check_harness_serve_surface(mode, has_explore_cfg));
let candidates = registry::search_path();
if candidates.iter().any(|c| c.exists) {
let reg_root = registry::root();
checks.push(Check {
group: "universal",
name: "registry_root",
status: Status::Ok,
detail: reg_root.display().to_string(),
hint: None,
});
} else {
checks.push(Check {
group: "universal",
name: "registry_root",
status: Status::Fail,
detail: "no registry candidate exists on disk".to_string(),
hint: Some("grove fetch".to_string()),
});
}
match registry::cache_root() {
Some(cache) => checks.push(Check {
group: "universal",
name: "grammar_cache",
status: Status::Ok,
detail: cache.display().to_string(),
hint: None,
}),
None => checks.push(Check {
group: "universal",
name: "grammar_cache",
status: Status::Warn,
detail: "no OS cache root resolved".to_string(),
hint: None,
}),
}
{
let lock_path = root.join("grove.lock");
if lock_path.exists() {
match registry::locked_langs(&lock_path) {
Ok(langs) if !langs.is_empty() => {
let available = registry::available();
let missing: Vec<_> = langs
.iter()
.filter(|l| !available.contains(l))
.cloned()
.collect();
if missing.is_empty() {
checks.push(Check {
group: "universal",
name: "project_languages",
status: Status::Ok,
detail: langs.join(", "),
hint: None,
});
} else {
checks.push(Check {
group: "universal",
name: "project_languages",
status: Status::Warn,
detail: format!(
"locked: {}; missing from registry: {}",
langs.join(", "),
missing.join(", ")
),
hint: Some("grove fetch".to_string()),
});
}
}
Ok(_) => checks.push(Check {
group: "universal",
name: "project_languages",
status: Status::Info,
detail: "grove.lock is empty — no languages pinned".to_string(),
hint: None,
}),
Err(e) => checks.push(Check {
group: "universal",
name: "project_languages",
status: Status::Warn,
detail: format!("could not read grove.lock: {e}"),
hint: None,
}),
}
} else {
checks.push(Check {
group: "universal",
name: "project_languages",
status: Status::Info,
detail: "grove.lock absent — run `grove init` to pin languages".to_string(),
hint: Some("grove init".to_string()),
});
}
}
{
let lock_path = root.join("grove.lock");
match registry::verify_lock(&lock_path) {
Ok(None) => checks.push(Check {
group: "universal",
name: "lock_integrity",
status: Status::Warn,
detail: "grove.lock absent".to_string(),
hint: Some("grove init".to_string()),
}),
Ok(Some(entries)) => {
let mut any_fail = false;
let mut any_warn = false;
let mut details = Vec::new();
for e in &entries {
match e.status {
LockVerifyStatus::Match => {
details.push(format!("{}: ok", e.lang));
}
LockVerifyStatus::Mismatch => {
any_fail = true;
details.push(format!("{}: hash mismatch", e.lang));
}
LockVerifyStatus::Missing => {
any_warn = true;
details.push(format!("{}: wasm not found", e.lang));
}
}
}
let status = if any_fail {
Status::Fail
} else if any_warn {
Status::Warn
} else {
Status::Ok
};
let hint = if any_fail || any_warn {
Some("grove fetch".to_string())
} else {
None
};
checks.push(Check {
group: "universal",
name: "lock_integrity",
status,
detail: details.join(", "),
hint,
});
}
Err(e) => checks.push(Check {
group: "universal",
name: "lock_integrity",
status: Status::Warn,
detail: format!("could not verify grove.lock: {e}"),
hint: None,
}),
}
}
if mode == Mode::McpLlm {
let explore_cfg = cfg_result.ok().and_then(|c| c.explore);
checks.extend(explore_checks(explore_cfg.as_ref()));
}
Report {
mode: declared_mode,
checks,
}
}
fn check_harness_mcp_json(root: &Path, mode: Mode) -> Check {
let path = root.join(".mcp.json");
let expected_args = harness::expected_mcp_args(mode);
let actual_args: Option<Vec<String>> = (|| -> Option<Vec<String>> {
let text = std::fs::read_to_string(&path).ok()?;
let doc: serde_json::Value = serde_json::from_str(&text).ok()?;
let args = doc["mcpServers"][harness::MCP_SERVER_KEY]["args"].as_array()?;
Some(
args.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
)
})();
match (expected_args, actual_args) {
(None, None) => Check {
group: "universal",
name: "harness_mcp_json",
status: Status::Ok,
detail: format!(
"mode={}: no grove entry expected and none present",
mode_name(mode)
),
hint: None,
},
(None, Some(_)) => Check {
group: "universal",
name: "harness_mcp_json",
status: Status::Fail,
detail: format!(
"mode={}: .mcp.json has a grove entry but none is expected",
mode_name(mode)
),
hint: Some(format!("grove init --as {}", mode_name(mode))),
},
(Some(expected), None) => Check {
group: "universal",
name: "harness_mcp_json",
status: Status::Fail,
detail: format!(
"mode={}: .mcp.json absent or no grove entry; expected args {:?}",
mode_name(mode),
expected
),
hint: Some(format!("grove init --as {}", mode_name(mode))),
},
(Some(expected), Some(actual)) => {
let expected_strs: Vec<&str> = expected.to_vec();
let actual_refs: Vec<&str> = actual.iter().map(String::as_str).collect();
if expected_strs == actual_refs {
Check {
group: "universal",
name: "harness_mcp_json",
status: Status::Ok,
detail: format!("mode={}: args {:?}", mode_name(mode), actual),
hint: None,
}
} else {
Check {
group: "universal",
name: "harness_mcp_json",
status: Status::Fail,
detail: format!(
"mode={}: expected args {:?}, found {:?}",
mode_name(mode),
expected_strs,
actual
),
hint: Some(format!("grove init --as {}", mode_name(mode))),
}
}
}
}
}
fn read_grove_args(path: &Path, format: McpFormat) -> Option<Vec<String>> {
let text = std::fs::read_to_string(path).ok()?;
match format {
McpFormat::Json { root_key, .. } => {
let doc: serde_json::Value = serde_json::from_str(&text).ok()?;
let args = doc[root_key][harness::MCP_SERVER_KEY]["args"].as_array()?;
Some(args.iter().filter_map(|v| v.as_str().map(String::from)).collect())
}
McpFormat::Toml { table } => {
let doc: toml_edit::DocumentMut = text.parse().ok()?;
let args = doc.get(table)?.get(harness::MCP_SERVER_KEY)?.get("args")?.as_array()?;
Some(args.iter().filter_map(|v| v.as_str().map(String::from)).collect())
}
}
}
fn harness_reg_check_name(h: HarnessId) -> &'static str {
match h {
HarnessId::ClaudeCode => "harness_mcp_json",
HarnessId::Cursor => "harness_mcp_cursor",
HarnessId::Codex => "harness_mcp_codex",
HarnessId::Gemini => "harness_mcp_gemini",
HarnessId::Windsurf => "harness_mcp_windsurf",
HarnessId::VsCode => "harness_mcp_vscode",
}
}
fn check_harness_registration(root: &Path, home: &Path, h: HarnessId, mode: Mode) -> Check {
let name = harness_reg_check_name(h);
let path = h.mcp_config_path_in(root, Some(home));
let label = h.display_name();
let expected_args = harness::expected_mcp_args(mode);
let actual_args = read_grove_args(&path, h.mcp_format());
let (status, detail) = match (expected_args, actual_args) {
(None, None) => (
Status::Ok,
format!("{label}: no grove entry expected and none present"),
),
(None, Some(_)) => (
Status::Fail,
format!("{label}: registration has a grove entry but none is expected"),
),
(Some(expected), None) => (
Status::Fail,
format!("{label}: registration absent or no grove entry; expected args {expected:?}"),
),
(Some(expected), Some(actual)) => {
let actual_refs: Vec<&str> = actual.iter().map(String::as_str).collect();
if expected == actual_refs.as_slice() {
(Status::Ok, format!("{label}: args {actual:?}"))
} else {
(
Status::Fail,
format!("{label}: expected args {expected:?}, found {actual:?}"),
)
}
}
};
let hint = matches!(status, Status::Fail).then(|| format!("grove init --as {}", mode_name(mode)));
Check { group: "universal", name, status, detail, hint }
}
fn check_harness_claude_md(root: &Path, mode: Mode, claude_selected: bool) -> Check {
let path = root.join("CLAUDE.md");
let expected_marker = if claude_selected {
harness::expected_claude_marker(mode)
} else {
None
};
let text = std::fs::read_to_string(&path).ok();
match expected_marker {
None => {
let has_block = text
.as_deref()
.map(|t| t.contains(harness::GROVE_START))
.unwrap_or(false);
if has_block {
Check {
group: "universal",
name: "harness_claude_md",
status: Status::Warn,
detail: format!(
"mode={}: CLAUDE.md has a grove block but none is expected",
mode_name(mode)
),
hint: Some(format!("grove init --as {}", mode_name(mode))),
}
} else {
Check {
group: "universal",
name: "harness_claude_md",
status: Status::Ok,
detail: format!(
"mode={}: no grove block expected and none present",
mode_name(mode)
),
hint: None,
}
}
}
Some(marker) => match text.as_deref() {
None => Check {
group: "universal",
name: "harness_claude_md",
status: Status::Fail,
detail: format!("mode={}: CLAUDE.md is absent", mode_name(mode)),
hint: Some(format!("grove init --as {}", mode_name(mode))),
},
Some(t) if !t.contains(harness::GROVE_START) => Check {
group: "universal",
name: "harness_claude_md",
status: Status::Fail,
detail: format!(
"mode={}: CLAUDE.md has no grove sentinel block",
mode_name(mode)
),
hint: Some(format!("grove init --as {}", mode_name(mode))),
},
Some(t) if !t.contains(marker) => Check {
group: "universal",
name: "harness_claude_md",
status: Status::Fail,
detail: format!(
"mode={}: CLAUDE.md block is missing expected marker {:?}",
mode_name(mode),
marker
),
hint: Some(format!("grove init --as {}", mode_name(mode))),
},
Some(_) => Check {
group: "universal",
name: "harness_claude_md",
status: Status::Ok,
detail: format!(
"mode={}: grove block present with {:?}",
mode_name(mode),
marker
),
hint: None,
},
},
}
}
fn check_harness_agents_md(root: &Path, mode: Mode) -> Check {
let path = root.join("AGENTS.md");
let expected = harness::agents_md_expected(mode);
let text = std::fs::read_to_string(&path).ok();
if expected {
match text.as_deref() {
None => Check {
group: "universal",
name: "harness_agents_md",
status: Status::Warn,
detail: format!(
"mode={}: AGENTS.md absent (expected for explore mode)",
mode_name(mode)
),
hint: Some(format!("grove init --as {}", mode_name(mode))),
},
Some(t) if !t.contains(harness::GROVE_START) => Check {
group: "universal",
name: "harness_agents_md",
status: Status::Warn,
detail: format!(
"mode={}: AGENTS.md has no grove sentinel block",
mode_name(mode)
),
hint: Some(format!("grove init --as {}", mode_name(mode))),
},
Some(_) => Check {
group: "universal",
name: "harness_agents_md",
status: Status::Ok,
detail: format!("mode={}: grove block present in AGENTS.md", mode_name(mode)),
hint: None,
},
}
} else {
let has_block = text
.as_deref()
.map(|t| t.contains(harness::GROVE_START))
.unwrap_or(false);
if has_block {
Check {
group: "universal",
name: "harness_agents_md",
status: Status::Warn,
detail: format!(
"mode={}: AGENTS.md has a grove block but none is expected",
mode_name(mode)
),
hint: Some(format!("grove init --as {}", mode_name(mode))),
}
} else {
Check {
group: "universal",
name: "harness_agents_md",
status: Status::Ok,
detail: format!(
"mode={}: no grove block in AGENTS.md (correct)",
mode_name(mode)
),
hint: None,
}
}
}
}
fn check_harness_serve_surface(mode: Mode, has_explore_cfg: bool) -> Check {
let surface = if mode == Mode::McpLlm {
if has_explore_cfg {
"Explore (when provider is healthy)"
} else {
"Standard (explore config missing; would fall back)"
}
} else {
"Standard"
};
Check {
group: "universal",
name: "harness_serve_surface",
status: Status::Info,
detail: format!("{surface} · mode={}", mode_name(mode)),
hint: None,
}
}
fn explore_checks(cfg: Option<&ExploreConfig>) -> Vec<Check> {
let mut checks = Vec::new();
let cfg = match cfg {
None => {
checks.push(Check {
group: "explore",
name: "explore_config_valid",
status: Status::Fail,
detail: "explore section missing from .grove/config.json".to_string(),
hint: Some("grove config".to_string()),
});
return checks;
}
Some(c) => match c.validate() {
Ok(()) => {
checks.push(Check {
group: "explore",
name: "explore_config_valid",
status: Status::Ok,
detail: format!("base_url={} model={}", c.base_url, c.model),
hint: None,
});
c
}
Err(e) => {
checks.push(Check {
group: "explore",
name: "explore_config_valid",
status: Status::Fail,
detail: format!("invalid explore config: {e}"),
hint: Some("grove config".to_string()),
});
return checks;
}
},
};
match health_probe(cfg) {
Ok(()) => {
checks.push(Check {
group: "explore",
name: "provider_reachable",
status: Status::Ok,
detail: format!("reachable: {}", cfg.base_url),
hint: None,
});
checks.push(Check {
group: "explore",
name: "model_served",
status: Status::Ok,
detail: format!("model {} is served", cfg.model),
hint: None,
});
}
Err(HealthError::Unreachable { url, detail }) => {
checks.push(Check {
group: "explore",
name: "provider_reachable",
status: Status::Fail,
detail: format!("unreachable: {url} ({detail})"),
hint: Some("start your local model provider and retry".to_string()),
});
checks.push(Check {
group: "explore",
name: "model_served",
status: Status::Info,
detail: "skipped — provider unreachable".to_string(),
hint: None,
});
}
Err(HealthError::ModelMissing {
model,
url,
available,
}) => {
checks.push(Check {
group: "explore",
name: "provider_reachable",
status: Status::Ok,
detail: format!("reachable: {url}"),
hint: None,
});
let avail_str = if available.is_empty() {
"none".to_string()
} else {
available.join(", ")
};
checks.push(Check {
group: "explore",
name: "model_served",
status: Status::Fail,
detail: format!("model {model} not found at {url}; available: {avail_str}"),
hint: Some(format!(
"pull {model} or set a different model in `grove config`"
)),
});
}
}
{
use crate::explore::toolset;
let known = [
toolset::READ,
toolset::GLOB,
toolset::GREP,
"grove",
"rg",
"grep",
"find",
];
let unknown: Vec<_> = cfg
.allowed_tools
.iter()
.filter(|t| !known.contains(&t.as_str()))
.cloned()
.collect();
if unknown.is_empty() {
checks.push(Check {
group: "explore",
name: "allowed_tools_known",
status: Status::Ok,
detail: format!("tools: {}", cfg.allowed_tools.join(", ")),
hint: None,
});
} else {
checks.push(Check {
group: "explore",
name: "allowed_tools_known",
status: Status::Warn,
detail: format!("unrecognized tools: {}", unknown.join(", ")),
hint: Some("check allowed_tools in .grove/config.json".to_string()),
});
}
}
checks.push(Check {
group: "explore",
name: "tap_config",
status: Status::Info,
detail: format!("tap={} trace_retain={}", cfg.tap, cfg.trace_retain),
hint: None,
});
checks
}
fn mode_name(mode: Mode) -> &'static str {
match mode {
Mode::Mcp => "mcp",
Mode::Skill => "skill",
Mode::Both => "both",
Mode::McpLlm => "mcp-llm",
Mode::Grammars => "grammars",
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmp(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir()
.join(format!("grove_doctor_{}_{tag}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
dir
}
fn write_config(dir: &Path, mode: &str) {
let grove = dir.join(".grove");
fs::create_dir_all(&grove).unwrap();
fs::write(
grove.join("config.json"),
format!(r#"{{"version":1,"mode":"{mode}"}}"#),
)
.unwrap();
}
fn write_mcp_json(dir: &Path, args: &[&str]) {
let exe = std::env::current_exe().unwrap();
let args_json: Vec<_> = args.iter().map(|a| format!(r#""{a}""#)).collect();
let args_str = args_json.join(",");
fs::write(
dir.join(".mcp.json"),
format!(
r#"{{"mcpServers":{{"grove":{{"command":"{}","args":[{args_str}]}}}}}}"#,
exe.display()
),
)
.unwrap();
}
fn write_claude_md(dir: &Path, marker: &str) {
fs::write(
dir.join("CLAUDE.md"),
format!("{}\n## grove\n{}\n{}\n", harness::GROVE_START, marker, harness::GROVE_END),
)
.unwrap();
}
fn write_agents_md(dir: &Path) {
fs::write(
dir.join("AGENTS.md"),
format!(
"{}\n## grove explore\n{}\n",
harness::GROVE_START,
harness::GROVE_END
),
)
.unwrap();
}
#[test]
fn warn_only_report_exits_zero() {
let report = Report {
mode: Mode::Mcp,
checks: vec![
Check {
group: "universal",
name: "something",
status: Status::Warn,
detail: "a warning".to_string(),
hint: None,
},
Check {
group: "universal",
name: "another",
status: Status::Info,
detail: "info".to_string(),
hint: None,
},
],
};
assert!(report.ok(), "warn-only report must return ok=true");
}
fn seed_harness_for_mode(dir: &Path, mode: Mode) {
match mode {
Mode::Mcp | Mode::Both => {
write_mcp_json(dir, &["serve"]);
write_claude_md(dir, "mcp__grove__outline");
write_agents_md(dir);
}
Mode::McpLlm => {
write_mcp_json(dir, &["serve", "--explore"]);
write_claude_md(dir, "mcp__grove__explore");
write_agents_md(dir);
}
Mode::Skill => {
write_claude_md(dir, "grove skill");
}
Mode::Grammars => {
}
}
}
#[test]
fn harness_matrix_clean_fixtures_all_ok() {
let modes = [
("mcp", Mode::Mcp),
("skill", Mode::Skill),
("both", Mode::Both),
("mcp-llm", Mode::McpLlm),
("grammars", Mode::Grammars),
];
for (mode_str, mode) in &modes {
let dir = tmp(&format!("matrix_{mode_str}"));
write_config(&dir, mode_str);
seed_harness_for_mode(&dir, *mode);
let report = diagnose(&dir, ModeChoice::None);
let harness_checks: Vec<_> = report
.checks
.iter()
.filter(|c| c.name.starts_with("harness_") && c.name != "harness_serve_surface")
.collect();
for chk in &harness_checks {
assert!(
matches!(chk.status, Status::Ok),
"mode={mode_str}: check {} was {:?}: {}",
chk.name,
chk.status,
chk.detail
);
}
}
}
#[test]
fn multi_harness_config_checks_each_registration() {
let dir = tmp("multi_doctor_ok");
std::fs::create_dir_all(dir.join(".grove")).unwrap();
std::fs::write(
dir.join(".grove").join("config.json"),
r#"{"version":1,"mode":"mcp","harnesses":["claude-code","cursor"]}"#,
)
.unwrap();
write_mcp_json(dir.as_path(), &["serve"]);
write_claude_md(&dir, "mcp__grove__outline");
write_agents_md(&dir);
std::fs::create_dir_all(dir.join(".cursor")).unwrap();
std::fs::write(
dir.join(".cursor").join("mcp.json"),
r#"{"mcpServers":{"grove":{"command":"g","args":["serve"]}}}"#,
)
.unwrap();
let report = diagnose(&dir, ModeChoice::None);
let cursor = report.checks.iter().find(|c| c.name == "harness_mcp_cursor").unwrap();
assert!(matches!(cursor.status, Status::Ok), "cursor OK: {}", cursor.detail);
let claude = report.checks.iter().find(|c| c.name == "harness_mcp_json").unwrap();
assert!(matches!(claude.status, Status::Ok), "claude OK: {}", claude.detail);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn multi_harness_missing_cursor_registration_is_fail() {
let dir = tmp("multi_doctor_fail");
std::fs::create_dir_all(dir.join(".grove")).unwrap();
std::fs::write(
dir.join(".grove").join("config.json"),
r#"{"version":1,"mode":"mcp","harnesses":["claude-code","cursor"]}"#,
)
.unwrap();
write_mcp_json(dir.as_path(), &["serve"]);
write_claude_md(&dir, "mcp__grove__outline");
write_agents_md(&dir);
let report = diagnose(&dir, ModeChoice::None);
let cursor = report.checks.iter().find(|c| c.name == "harness_mcp_cursor").unwrap();
assert!(matches!(cursor.status, Status::Fail), "missing cursor reg → Fail: {}", cursor.detail);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn mcp_mode_with_explore_args_in_mcp_json_is_fail() {
let dir = tmp("drift_mcp_json");
write_config(&dir, "mcp");
write_mcp_json(dir.as_path(), &["serve", "--explore"]); write_claude_md(&dir, "mcp__grove__outline");
let report = diagnose(&dir, ModeChoice::None);
let chk = report
.checks
.iter()
.find(|c| c.name == "harness_mcp_json")
.unwrap();
assert!(
matches!(chk.status, Status::Fail),
"expected Fail for mcp mode with explore args, got {:?}: {}",
chk.status,
chk.detail
);
}
#[test]
fn mcp_mode_with_explore_marker_in_claude_md_is_fail() {
let dir = tmp("drift_claude_md");
write_config(&dir, "mcp");
write_mcp_json(dir.as_path(), &["serve"]);
write_claude_md(&dir, "mcp__grove__explore");
let report = diagnose(&dir, ModeChoice::None);
let chk = report
.checks
.iter()
.find(|c| c.name == "harness_claude_md")
.unwrap();
assert!(
matches!(chk.status, Status::Fail),
"expected Fail for mcp mode with explore marker, got {:?}: {}",
chk.status,
chk.detail
);
}
#[test]
fn mcp_llm_mode_without_agents_md_is_warn() {
let dir = tmp("drift_agents_md");
write_config(&dir, "mcp-llm");
write_mcp_json(dir.as_path(), &["serve", "--explore"]);
write_claude_md(&dir, "mcp__grove__explore");
let report = diagnose(&dir, ModeChoice::None);
let chk = report
.checks
.iter()
.find(|c| c.name == "harness_agents_md")
.unwrap();
assert!(
matches!(chk.status, Status::Warn),
"expected Warn for mcp-llm without AGENTS.md, got {:?}: {}",
chk.status,
chk.detail
);
}
#[test]
fn grammars_mode_with_grove_block_in_claude_md_is_warn() {
let dir = tmp("drift_grammars");
write_config(&dir, "grammars");
write_claude_md(&dir, "mcp__grove__outline");
let report = diagnose(&dir, ModeChoice::None);
let chk = report
.checks
.iter()
.find(|c| c.name == "harness_claude_md")
.unwrap();
assert!(
matches!(chk.status, Status::Warn),
"expected Warn for grammars with grove block in CLAUDE.md, got {:?}: {}",
chk.status,
chk.detail
);
}
#[test]
fn lock_integrity_absent_lockfile_is_warn() {
let dir = tmp("lock_absent");
write_config(&dir, "mcp");
seed_harness_for_mode(&dir, Mode::Mcp);
let report = diagnose(&dir, ModeChoice::None);
let chk = report
.checks
.iter()
.find(|c| c.name == "lock_integrity")
.unwrap();
assert!(
matches!(chk.status, Status::Warn),
"expected Warn for absent grove.lock, got {:?}: {}",
chk.status,
chk.detail
);
}
#[test]
fn explore_config_absent_is_fail() {
let dir = tmp("explore_absent");
write_config(&dir, "mcp-llm");
write_mcp_json(&dir, &["serve", "--explore"]);
write_claude_md(&dir, "mcp__grove__explore");
write_agents_md(&dir);
let report = diagnose(&dir, ModeChoice::None);
let chk = report
.checks
.iter()
.find(|c| c.name == "explore_config_valid")
.unwrap();
assert!(
matches!(chk.status, Status::Fail),
"expected Fail for absent explore config, got {:?}: {}",
chk.status,
chk.detail
);
}
#[test]
fn provider_unreachable_is_fail() {
let dir = tmp("explore_unreachable");
let grove = dir.join(".grove");
fs::create_dir_all(&grove).unwrap();
let cfg_json = r#"{
"version": 1,
"mode": "mcp-llm",
"explore": {
"provider": "ollama",
"base_url": "http://127.0.0.1:19999/v1",
"model": "nonexistent",
"steering": "standard",
"allowed_tools": ["grove"],
"tap": false
}
}"#;
fs::write(grove.join("config.json"), cfg_json).unwrap();
write_mcp_json(&dir, &["serve", "--explore"]);
write_claude_md(&dir, "mcp__grove__explore");
write_agents_md(&dir);
let report = diagnose(&dir, ModeChoice::None);
let chk = report
.checks
.iter()
.find(|c| c.name == "provider_reachable")
.unwrap();
assert!(
matches!(chk.status, Status::Fail),
"expected Fail for unreachable provider, got {:?}: {}",
chk.status,
chk.detail
);
}
}