#[allow(clippy::wildcard_imports)]
use super::*;
pub(crate) fn pinned_data_dir_ok(env_obj: Option<&serde_json::Value>, data_dir: &str) -> bool {
match env_obj
.and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
.and_then(|d| d.as_str())
{
Some(d) => d.trim() == data_dir.trim(),
None => true,
}
}
pub(crate) fn check_mcp_json(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
if !path.exists() {
return NamedCheck {
name: "MCP config".to_string(),
ok: false,
detail: format!("missing ({})", path.display()),
};
}
let content = std::fs::read_to_string(path).unwrap_or_default();
let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
let Some(v) = parsed else {
return NamedCheck {
name: "MCP config".to_string(),
ok: false,
detail: format!("invalid JSON ({})", path.display()),
};
};
let entry = v
.get("mcpServers")
.and_then(|m| m.get("lean-ctx"))
.cloned()
.or_else(|| {
v.get("mcp")
.and_then(|m| m.get("servers"))
.and_then(|m| m.get("lean-ctx"))
.cloned()
});
let Some(e) = entry else {
return NamedCheck {
name: "MCP config".to_string(),
ok: false,
detail: format!("lean-ctx missing ({})", path.display()),
};
};
let cmd_ok = e
.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| cmd_matches_expected(c, binary));
let env_ok = pinned_data_dir_ok(e.get("env"), data_dir);
let ok = cmd_ok && env_ok;
let detail = if ok {
format!("ok ({})", path.display())
} else {
format!("drift ({})", path.display())
};
NamedCheck {
name: "MCP config".to_string(),
ok,
detail,
}
}
pub(crate) fn check_jetbrains_snippet(
path: &std::path::Path,
binary: &str,
data_dir: &str,
) -> NamedCheck {
let mut c = check_mcp_json(path, binary, data_dir);
c.name = "MCP snippet".to_string();
if c.ok {
c.detail = format!(
"ready — paste into Settings → Tools → AI Assistant → MCP ({})",
path.display()
);
}
c
}
pub(crate) fn cmd_matches_expected(cmd: &str, portable: &str) -> bool {
let cmd = cmd.trim();
if cmd == portable.trim() {
return true;
}
if cmd == "lean-ctx" {
return true;
}
if let Some(override_cmd) = crate::core::portable_binary::hook_binary_override()
&& cmd == override_cmd.trim()
{
return true;
}
if let Some(resolved) = resolve_lean_ctx_binary()
&& cmd == resolved.to_string_lossy().trim()
{
return true;
}
false
}
pub(crate) fn hook_binary_refs(content: &str) -> Vec<String> {
let pieces: Vec<&str> = content.split(" hook ").collect();
if pieces.len() < 2 {
return Vec::new();
}
pieces[..pieces.len() - 1]
.iter()
.filter_map(|piece| {
piece
.rsplit(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '`')
.find(|tok| !tok.is_empty())
.map(|tok| tok.trim_end_matches(',').to_string())
})
.filter(|tok| tok.contains("lean-ctx"))
.collect()
}
pub(crate) fn stale_hook_binary(content: &str, binary: &str) -> Option<String> {
let refs = hook_binary_refs(content);
if refs.is_empty() || refs.iter().any(|r| cmd_matches_expected(r, binary)) {
return None;
}
refs.into_iter().next()
}
pub(crate) fn check_rules_file(path: &std::path::Path) -> NamedCheck {
let ok = path.exists();
NamedCheck {
name: "Rules file".to_string(),
ok,
detail: if ok {
path.display().to_string()
} else {
format!("missing ({})", path.display())
},
}
}
pub(crate) fn check_windsurf_hooks(home: &std::path::Path) -> NamedCheck {
let path = home.join(".codeium/windsurf/hooks.json");
let ok = std::fs::read_to_string(&path).is_ok_and(|c| c.contains("hook observe"));
NamedCheck {
name: "Cascade hooks".to_string(),
ok,
detail: if ok {
format!("ok ({})", path.display())
} else {
"missing (run: lean-ctx setup)".to_string()
},
}
}
pub(crate) fn skill_not_applicable_note() -> NamedCheck {
NamedCheck {
name: "Skill".to_string(),
ok: true,
detail: "N/A by design — Windsurf uses MCP + rules + Cascade hooks".to_string(),
}
}
pub(crate) fn last_ctx_call_check() -> NamedCheck {
let detail = match last_ctx_call_ago() {
Some(ago) => format!("{ago} (most recent ctx_* MCP call)"),
None => "never — the agent has not called any ctx_* MCP tool yet".to_string(),
};
NamedCheck {
name: "Last ctx_* call".to_string(),
ok: true,
detail,
}
}
pub(crate) fn last_ctx_call_ago() -> Option<String> {
let path = crate::core::paths::state_dir().ok()?.join("events.jsonl");
let content = std::fs::read_to_string(&path).ok()?;
let ts = content
.lines()
.rev()
.filter_map(|l| serde_json::from_str::<crate::core::events::LeanCtxEvent>(l).ok())
.find_map(|ev| match ev.kind {
crate::core::events::EventKind::ToolCall { tool, .. } if tool.starts_with("ctx_") => {
Some(ev.timestamp)
}
_ => None,
})?;
let parsed = chrono::NaiveDateTime::parse_from_str(&ts, "%Y-%m-%dT%H:%M:%S%.3f").ok()?;
let delta = chrono::Local::now()
.naive_local()
.signed_duration_since(parsed);
Some(humanize_ago(delta))
}
pub(crate) fn humanize_ago(d: chrono::Duration) -> String {
let secs = d.num_seconds().max(0);
if secs < 60 {
format!("{secs}s ago")
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86_400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86_400)
}
}
pub(crate) fn rules_path_for(name: &str, home: &std::path::Path) -> Option<std::path::PathBuf> {
match name {
"Windsurf" => Some(home.join(".codeium/windsurf/rules/lean-ctx.md")),
"Cline" => Some(home.join(".cline/rules/lean-ctx.md")),
"Roo Code" => Some(home.join(".roo/rules/lean-ctx.md")),
"OpenCode" => Some(home.join(".config/opencode/AGENTS.md")),
"AWS Kiro" => Some(home.join(".kiro/steering/lean-ctx.md")),
"Verdent" => Some(home.join(".verdent/rules/lean-ctx.md")),
"Trae" => Some(home.join(".trae/rules/lean-ctx.md")),
"Qwen Code" => Some(home.join(".qwen/rules/lean-ctx.md")),
"Amazon Q Developer" => Some(home.join(".aws/amazonq/rules/lean-ctx.md")),
"JetBrains IDEs" => Some(home.join(".jb-rules/lean-ctx.md")),
"Antigravity" => Some(home.join(".gemini/antigravity/rules/lean-ctx.md")),
"Augment CLI" | "Augment (VS Code)" => Some(home.join(".augment/rules/lean-ctx.md")),
"Pi Coding Agent" => Some(home.join(".pi/rules/lean-ctx.md")),
"Crush" => Some(home.join(".config/crush/rules/lean-ctx.md")),
_ => None,
}
}