use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::core::rules_canonical::RulesFile;
pub use crate::core::rules_canonical::{END_MARK, RULES_VERSION, START_MARK};
mod content;
mod detect;
mod skills;
mod targets;
#[cfg(test)]
mod tests;
mod write;
pub use content::cursor_mdc_document;
pub use content::{
GEMINI_DEDICATED_CONTEXT_FILENAME, gemini_dedicated_rules_path, opencode_dedicated_rules_path,
};
pub use skills::{
install_all_skills, install_skill_for_agent, install_skills_where, planned_skill_files,
};
fn shared_surface_profile(
cfg: &crate::core::config::Config,
) -> crate::core::tool_profiles::ToolProfile {
crate::core::tool_visibility::guidance_profile(cfg, "")
}
pub fn canonical_rules_block() -> String {
let cfg = crate::core::config::Config::load();
let shadow = cfg.shadow_mode;
let level = crate::core::config::CompressionLevel::effective(&cfg);
let profile = shared_surface_profile(&cfg);
crate::core::rules_canonical::render(
shadow,
crate::core::rules_canonical::Wrapper::Shared,
level,
&profile,
)
}
pub fn rules_shared_content() -> String {
canonical_rules_block()
}
pub fn rules_dedicated_markdown() -> String {
let cfg = crate::core::config::Config::load();
let shadow = cfg.shadow_mode;
let level = crate::core::config::CompressionLevel::effective(&cfg);
let profile = shared_surface_profile(&cfg);
crate::core::rules_canonical::render(
shadow,
crate::core::rules_canonical::Wrapper::Dedicated,
level,
&profile,
)
}
pub fn rules_longform_markdown() -> String {
let cfg = crate::core::config::Config::load();
let shadow = cfg.shadow_mode;
let level = crate::core::config::CompressionLevel::effective(&cfg);
let profile = shared_surface_profile(&cfg);
crate::core::rules_canonical::render(
shadow,
crate::core::rules_canonical::Wrapper::Longform,
level,
&profile,
)
}
pub fn expected_blocks_by_target(
home: &std::path::Path,
) -> std::collections::HashMap<String, String> {
let injection = crate::core::config::Config::load().rules_injection_effective();
let cfg = crate::core::config::Config::load();
let shadow = cfg.shadow_mode;
let level = crate::core::config::CompressionLevel::effective(&cfg);
build_rules_targets(home, injection)
.into_iter()
.map(|target| {
let profile = write::profile_for_target(&cfg, target.name);
let wrapper = match target.format {
RulesFormat::SharedMarkdown => crate::core::rules_canonical::Wrapper::Shared,
RulesFormat::DedicatedMarkdown => crate::core::rules_canonical::Wrapper::Dedicated,
RulesFormat::CursorMdc => content::cursor_wrapper_for_mdc(&target.path),
};
let expected = crate::core::rules_canonical::render(shadow, wrapper, level, &profile);
(target.name.to_string(), expected)
})
.collect()
}
use detect::is_tool_detected;
use targets::build_rules_targets;
use write::inject_rules;
struct RulesTarget {
name: &'static str,
path: PathBuf,
format: RulesFormat,
}
enum RulesFormat {
SharedMarkdown,
DedicatedMarkdown,
CursorMdc,
}
#[derive(Debug, Default)]
pub struct InjectResult {
pub injected: Vec<String>,
pub updated: Vec<String>,
pub already: Vec<String>,
pub errors: Vec<String>,
pub backed_up: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RulesTargetStatus {
pub name: String,
pub detected: bool,
pub path: String,
pub state: String,
pub note: Option<String>,
}
enum RulesResult {
Updated,
AlreadyPresent,
}
pub fn inject_all_rules(home: &std::path::Path) -> InjectResult {
inject_rules_where(home, &|_| true)
}
fn rules_agent_key(target_name: &str) -> Option<&'static str> {
Some(match target_name {
"Gemini CLI" => "gemini",
"VS Code" => "vscode",
"Copilot CLI" => "copilot",
"Cursor" => "cursor",
"Windsurf" => "windsurf",
"Zed" => "zed",
"Cline" => "cline",
"Roo Code" => "roo",
"OpenCode" => "opencode",
"Continue" => "continue",
"Amp" => "amp",
"Qwen Code" => "qwen",
"Trae" => "trae",
"Amazon Q Developer" => "amazonq",
"JetBrains IDEs" => "jetbrains",
"Antigravity" => "antigravity",
"Pi Coding Agent" => "pi",
"AWS Kiro" => "kiro",
"Verdent" => "verdent",
"Crush" => "crush",
"Augment" => "augment",
"OpenClaw" => "openclaw",
"Codex CLI" => "codex",
"Grok" => "grok",
"Oh My Pi" => "omp",
"Hermes Agent" => "hermes",
"Qoder" => "qoder",
_ => return None,
})
}
#[must_use]
pub fn planned_rules_files(
home: &std::path::Path,
allows: &dyn Fn(&str) -> bool,
) -> Vec<(String, PathBuf)> {
let cfg = crate::core::config::Config::load();
let injection = cfg.rules_injection_effective();
if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project
|| injection == crate::core::config::RulesInjection::Off
{
return Vec::new();
}
let mut files = Vec::new();
if injection == crate::core::config::RulesInjection::Shared
&& allows("claude")
&& detect::is_named_agent_ready("Claude Code", home)
{
files.push((
"Claude Code".to_string(),
crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"),
));
}
for target in build_rules_targets(home, injection) {
if rules_agent_key(target.name).is_some_and(allows) && is_tool_detected(&target, home) {
files.push((target.name.to_string(), target.path));
}
}
files
}
pub fn inject_rules_where(home: &std::path::Path, allows: &dyn Fn(&str) -> bool) -> InjectResult {
let cfg = crate::core::config::Config::load();
if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
return InjectResult::default();
}
if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
return InjectResult::default();
}
let targets = build_rules_targets(home, cfg.rules_injection_effective());
let mut result = InjectResult::default();
if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Shared
&& allows("claude")
&& detect::is_named_agent_ready("Claude Code", home)
{
sync_claude_pointer_block(home, &mut result);
}
for target in &targets {
if !rules_agent_key(target.name).is_some_and(allows) {
continue;
}
if !is_tool_detected(target, home) {
continue;
}
if rules_agent_key(target.name)
.is_some_and(|key| crate::core::governor_hosts::is_installed(key, home))
{
continue;
}
if !detect::is_mcp_configured(target, home) {
continue;
}
let bak_path = target.path.with_extension(format!(
"{}.bak",
target
.path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
));
let bak_existed_before = bak_path.exists();
let bak_mtime_before = bak_existed_before
.then(|| {
std::fs::metadata(&bak_path)
.ok()
.and_then(|m| m.modified().ok())
})
.flatten();
match inject_rules(target) {
Ok(RulesResult::Updated) => {
result.updated.push(target.name.to_string());
let bak_is_new = if bak_existed_before {
std::fs::metadata(&bak_path)
.ok()
.and_then(|m| m.modified().ok())
!= bak_mtime_before
} else {
bak_path.exists()
};
if bak_is_new {
result
.backed_up
.push(bak_path.to_string_lossy().to_string());
}
}
Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
Err(e) => result.errors.push(format!("{}: {e}", target.name)),
}
}
result
}
pub fn inject_rules_for_agent(home: &std::path::Path, agent_key: &str) -> InjectResult {
let cfg = crate::core::config::Config::load();
if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
return InjectResult::default();
}
if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
return InjectResult::default();
}
if crate::core::governor_hosts::is_installed(agent_key, home) {
return InjectResult::default();
}
let targets = build_rules_targets(home, cfg.rules_injection_effective());
let mut result = InjectResult::default();
if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Shared
&& is_claude_agent_key(agent_key)
{
sync_claude_pointer_block(home, &mut result);
return result;
}
for target in &targets {
if !match_agent_name(agent_key, target.name) {
continue;
}
let bak_path = target.path.with_extension(format!(
"{}.bak",
target
.path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
));
let bak_existed_before = bak_path.exists();
match inject_rules(target) {
Ok(RulesResult::Updated) => {
result.updated.push(target.name.to_string());
if !bak_existed_before && bak_path.exists() {
result
.backed_up
.push(bak_path.to_string_lossy().to_string());
}
}
Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
Err(e) => result.errors.push(format!("{}: {e}", target.name)),
}
}
result
}
fn is_claude_agent_key(agent_key: &str) -> bool {
matches!(
agent_key.trim().to_ascii_lowercase().as_str(),
"claude" | "claude-code" | "claude_code" | "claude code"
)
}
fn sync_claude_pointer_block(home: &std::path::Path, result: &mut InjectResult) {
let mode = crate::core::hook_mode::recommend_hook_mode("claude");
match crate::core::claude_md::sync_claude_global_rules_block(home, mode) {
Ok(true) => result.updated.push("Claude Code".to_string()),
Ok(false) => result.already.push("Claude Code".to_string()),
Err(error) => result.errors.push(format!("Claude Code: {error}")),
}
}
#[must_use]
pub fn any_rules_marker_present(home: &std::path::Path) -> bool {
use crate::core::config::RulesInjection;
let mut seen = std::collections::HashSet::new();
for injection in [RulesInjection::Shared, RulesInjection::Dedicated] {
for target in build_rules_targets(home, injection) {
if !seen.insert(target.path.clone()) {
continue;
}
if let Ok(content) = std::fs::read_to_string(&target.path)
&& RulesFile::parse(&content).has_content()
{
return true;
}
}
}
false
}
#[must_use]
pub fn host_guidance_present(home: &std::path::Path) -> bool {
use crate::core::editor_registry::{claude_state_dir, codebuddy_state_dir};
any_rules_marker_present(home)
|| pointer_block_present(&[
claude_state_dir(home).join("CLAUDE.md"),
codebuddy_state_dir(home).join("CODEBUDDY.md"),
])
}
fn pointer_block_present(files: &[std::path::PathBuf]) -> bool {
files.iter().any(|path| {
std::fs::read_to_string(path)
.is_ok_and(|c| c.contains(crate::core::rules_canonical::AGENTS_BLOCK_START))
})
}
fn match_agent_name(cli_key: &str, target_name: &str) -> bool {
let needle = cli_key.to_lowercase();
let tn = target_name.to_lowercase();
if needle == "pi" {
return tn.contains("pi coding");
}
if needle == "omp" {
return tn.contains("oh my pi");
}
needle.contains(&tn)
|| tn.contains(&needle)
|| (needle.contains("cursor") && tn.contains("cursor"))
|| (needle.contains("claude") && tn.contains("claude"))
|| (needle.contains("codebuddy") && tn.contains("codebuddy"))
|| (needle.contains("windsurf") && tn.contains("windsurf"))
|| (needle.contains("codex") && tn.contains("claude"))
|| (needle.contains("zed") && tn.contains("zed"))
|| (needle.contains("copilot") && tn.contains("copilot"))
|| (needle.contains("jetbrains") && tn.contains("jetbrains"))
|| (needle.contains("kiro") && tn.contains("kiro"))
|| (needle.contains("gemini") && tn.contains("gemini"))
|| (needle == "opencode" && tn.contains("opencode"))
|| (needle == "cline" && tn.contains("cline"))
|| (needle == "roo" && tn.contains("roo"))
|| (needle == "amp" && tn.contains("amp"))
|| (needle == "trae" && tn.contains("trae"))
|| (needle == "amazonq" && tn.contains("amazon"))
|| (needle == "crush" && tn.contains("crush"))
|| (needle == "verdent" && tn.contains("verdent"))
|| (needle == "continue" && tn.contains("continue"))
|| (needle == "qwen" && tn.contains("qwen"))
|| (needle == "antigravity" && tn.contains("antigravity"))
|| (needle == "augment" && tn.contains("augment"))
|| (needle == "openclaw" && tn.contains("openclaw"))
|| (needle == "grok" && tn.contains("grok"))
|| (needle == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
}
pub fn check_rules_freshness(client_name: &str) -> Option<String> {
let home = dirs::home_dir()?;
check_rules_freshness_at_home(client_name, &home)
}
fn check_rules_freshness_at_home(client_name: &str, home: &std::path::Path) -> Option<String> {
let cfg = crate::core::config::Config::load();
if cfg.declines_rule_steering()
|| cfg.rules_scope_effective() == crate::core::config::RulesScope::Project
{
return None;
}
let injection = cfg.rules_injection_effective();
let targets = build_rules_targets(home, injection);
let matched: Vec<&RulesTarget> = targets
.iter()
.filter(|t| match_agent_name(client_name, t.name))
.collect();
if matched.is_empty() {
return None;
}
for target in &matched {
if !is_tool_detected(target, home) || !detect::is_mcp_configured(target, home) {
continue;
}
if !target.path.exists() {
continue;
}
let content = std::fs::read_to_string(&target.path).ok()?;
let file = RulesFile::parse(&content);
if file.has_content() && !file.is_current() {
return Some(format!(
"[RULES OUTDATED] Your {} rules were written by an older lean-ctx version. \
Re-read your rules file ({}) or run `lean-ctx setup` to update, \
then start a new session for full compatibility.",
target.name,
target.path.display()
));
}
}
None
}
pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
let injection = crate::core::config::Config::load().rules_injection_effective();
let targets = build_rules_targets(home, injection);
let mut out = Vec::new();
for target in &targets {
let detected = is_tool_detected(target, home);
let path = target.path.to_string_lossy().to_string();
let state = if !detected {
"not_detected".to_string()
} else if !target.path.exists() {
"missing".to_string()
} else {
match std::fs::read_to_string(&target.path) {
Ok(content) => {
let file = RulesFile::parse(&content);
if file.has_content() {
if file.is_current() {
"up_to_date".to_string()
} else {
"outdated".to_string()
}
} else {
"present_without_marker".to_string()
}
}
Err(_) => "read_error".to_string(),
}
};
out.push(RulesTargetStatus {
name: target.name.to_string(),
detected,
path,
state,
note: None,
});
}
out
}