Skip to main content

lean_ctx/rules_inject/
mod.rs

1//! Rules + SKILL.md injection for every supported agent, split by concern
2//! (GL#440): `content` (payloads), `targets` (agent catalog), `detect`
3//! (installation checks), `write` (atomic file surgery), `skills` (SKILL.md).
4//! This hub owns the shared types and the orchestration entry points.
5//!
6//! Content is delegated to `core::rules_canonical` — all rule text lives
7//! there as `pub const` sections.  Markers (`START_MARK`, `END_MARK`,
8//! `RULES_VERSION`) are also re-exported from canonical.
9
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14use crate::core::rules_canonical::RulesFile;
15pub use crate::core::rules_canonical::{END_MARK, RULES_VERSION, START_MARK};
16
17mod content;
18mod detect;
19mod skills;
20mod targets;
21#[cfg(test)]
22mod tests;
23mod write;
24
25pub(crate) use content::cursor_mdc_document;
26pub use content::{
27    GEMINI_DEDICATED_CONTEXT_FILENAME, gemini_dedicated_rules_path, opencode_dedicated_rules_path,
28};
29pub use skills::{install_all_skills, install_skill_for_agent};
30
31/// Forwarding functions — content is delegated to `core::rules_canonical`.
32pub fn canonical_rules_block() -> String {
33    let cfg = crate::core::config::Config::load();
34    let shadow = cfg.shadow_mode;
35    let level = crate::core::config::CompressionLevel::effective(&cfg);
36    let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
37    crate::core::rules_canonical::render(
38        shadow,
39        crate::core::rules_canonical::Wrapper::Shared,
40        level,
41        &profile,
42    )
43}
44pub fn rules_shared_content() -> String {
45    canonical_rules_block()
46}
47pub fn rules_dedicated_markdown() -> String {
48    let cfg = crate::core::config::Config::load();
49    let shadow = cfg.shadow_mode;
50    let level = crate::core::config::CompressionLevel::effective(&cfg);
51    let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
52    crate::core::rules_canonical::render(
53        shadow,
54        crate::core::rules_canonical::Wrapper::Dedicated,
55        level,
56        &profile,
57    )
58}
59
60/// Long-form rules for the on-demand project `LEAN-CTX.md` (#578). Same
61/// config-driven shadow/compression handling as the dedicated render, but the
62/// LONGFORM profile with the verbose teaching sections.
63pub fn rules_longform_markdown() -> String {
64    let cfg = crate::core::config::Config::load();
65    let shadow = cfg.shadow_mode;
66    let level = crate::core::config::CompressionLevel::effective(&cfg);
67    let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
68    crate::core::rules_canonical::render(
69        shadow,
70        crate::core::rules_canonical::Wrapper::Longform,
71        level,
72        &profile,
73    )
74}
75
76/// The canonical rules block lean-ctx would write for each target, keyed by the
77/// target's display name.
78///
79/// Drift detection compares against this instead of guessing shared-vs-dedicated
80/// from a target's on-disk contents: a freshly synced `SharedMarkdown` file (e.g.
81/// Copilot CLI, Codex CLI) carries no user text, which a content heuristic
82/// mistook for the dedicated layout and then flagged as drifted on every sync.
83/// Keying by the real `RulesFormat` keeps `sync` and `diff` in agreement (#548).
84pub fn expected_blocks_by_target(
85    home: &std::path::Path,
86) -> std::collections::HashMap<String, String> {
87    let injection = crate::core::config::Config::load().rules_injection_effective();
88    let cfg = crate::core::config::Config::load();
89    let shadow = cfg.shadow_mode;
90    let level = crate::core::config::CompressionLevel::effective(&cfg);
91    let shared = canonical_rules_block();
92    let dedicated = rules_dedicated_markdown();
93    build_rules_targets(home, injection)
94        .into_iter()
95        .map(|target| {
96            let expected = match target.format {
97                RulesFormat::SharedMarkdown => shared.clone(),
98                RulesFormat::DedicatedMarkdown => dedicated.clone(),
99                // CursorMdc embeds the render verbatim between the markers
100                // (frontmatter lives outside them); the wrapper is dynamic —
101                // HookCovered on hook-covered installs (GL #1153).
102                RulesFormat::CursorMdc => {
103                    let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
104                    crate::core::rules_canonical::render(
105                        shadow,
106                        content::cursor_wrapper_for_mdc(&target.path),
107                        level,
108                        &profile,
109                    )
110                }
111            };
112            (target.name.to_string(), expected)
113        })
114        .collect()
115}
116
117use detect::is_tool_detected;
118use targets::build_rules_targets;
119use write::inject_rules;
120
121struct RulesTarget {
122    name: &'static str,
123    path: PathBuf,
124    format: RulesFormat,
125}
126
127enum RulesFormat {
128    SharedMarkdown,
129    DedicatedMarkdown,
130    CursorMdc,
131}
132
133#[derive(Debug, Default)]
134pub struct InjectResult {
135    pub injected: Vec<String>,
136    pub updated: Vec<String>,
137    pub already: Vec<String>,
138    pub errors: Vec<String>,
139    pub backed_up: Vec<String>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct RulesTargetStatus {
144    pub name: String,
145    pub detected: bool,
146    pub path: String,
147    pub state: String,
148    pub note: Option<String>,
149}
150
151enum RulesResult {
152    Updated,
153    AlreadyPresent,
154}
155
156pub fn inject_all_rules(home: &std::path::Path) -> InjectResult {
157    let cfg = crate::core::config::Config::load();
158    if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
159        return InjectResult::default();
160    }
161    if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
162        return InjectResult::default();
163    }
164
165    let targets = build_rules_targets(home, cfg.rules_injection_effective());
166
167    let mut result = InjectResult::default();
168
169    for target in &targets {
170        if !is_tool_detected(target, home) {
171            continue;
172        }
173        // GH #1435: skip rules injection when the tool's MCP server is not
174        // configured — a rules file that mandates ctx_* tools for an agent
175        // that can't reach them is a self-contradiction.
176        if !detect::is_mcp_configured(target, home) {
177            continue;
178        }
179
180        let bak_path = target.path.with_extension(format!(
181            "{}.bak",
182            target
183                .path
184                .extension()
185                .and_then(|e| e.to_str())
186                .unwrap_or("")
187        ));
188        let bak_existed_before = bak_path.exists();
189        let bak_mtime_before = bak_existed_before
190            .then(|| {
191                std::fs::metadata(&bak_path)
192                    .ok()
193                    .and_then(|m| m.modified().ok())
194            })
195            .flatten();
196
197        match inject_rules(target) {
198            Ok(RulesResult::Updated) => {
199                result.updated.push(target.name.to_string());
200                let bak_is_new = if bak_existed_before {
201                    std::fs::metadata(&bak_path)
202                        .ok()
203                        .and_then(|m| m.modified().ok())
204                        != bak_mtime_before
205                } else {
206                    bak_path.exists()
207                };
208                if bak_is_new {
209                    result
210                        .backed_up
211                        .push(bak_path.to_string_lossy().to_string());
212                }
213            }
214            Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
215            Err(e) => result.errors.push(format!("{}: {e}", target.name)),
216        }
217    }
218
219    result
220}
221
222/// Inject global rules for a single agent (by CLI key like "opencode", "cursor", etc.).
223pub fn inject_rules_for_agent(home: &std::path::Path, agent_key: &str) -> InjectResult {
224    let cfg = crate::core::config::Config::load();
225    if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
226        return InjectResult::default();
227    }
228    if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
229        return InjectResult::default();
230    }
231
232    let targets = build_rules_targets(home, cfg.rules_injection_effective());
233    let mut result = InjectResult::default();
234
235    for target in &targets {
236        if !match_agent_name(agent_key, target.name) {
237            continue;
238        }
239
240        let bak_path = target.path.with_extension(format!(
241            "{}.bak",
242            target
243                .path
244                .extension()
245                .and_then(|e| e.to_str())
246                .unwrap_or("")
247        ));
248        let bak_existed_before = bak_path.exists();
249
250        match inject_rules(target) {
251            Ok(RulesResult::Updated) => {
252                result.updated.push(target.name.to_string());
253                if !bak_existed_before && bak_path.exists() {
254                    result
255                        .backed_up
256                        .push(bak_path.to_string_lossy().to_string());
257                }
258            }
259            Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
260            Err(e) => result.errors.push(format!("{}: {e}", target.name)),
261        }
262    }
263
264    result
265}
266
267/// Returns `true` if a lean-ctx rules marker is present in *any* supported
268/// agent's rules file.
269#[must_use]
270pub fn any_rules_marker_present(home: &std::path::Path) -> bool {
271    use crate::core::config::RulesInjection;
272    let mut seen = std::collections::HashSet::new();
273    for injection in [RulesInjection::Shared, RulesInjection::Dedicated] {
274        for target in build_rules_targets(home, injection) {
275            if !seen.insert(target.path.clone()) {
276                continue;
277            }
278            if let Ok(content) = std::fs::read_to_string(&target.path)
279                && RulesFile::parse(&content).has_content()
280            {
281                return true;
282            }
283        }
284    }
285    false
286}
287
288fn match_agent_name(cli_key: &str, target_name: &str) -> bool {
289    let needle = cli_key.to_lowercase();
290    let tn = target_name.to_lowercase();
291    needle.contains(&tn)
292        || tn.contains(&needle)
293        || (needle.contains("cursor") && tn.contains("cursor"))
294        || (needle.contains("claude") && tn.contains("claude"))
295        || (needle.contains("codebuddy") && tn.contains("codebuddy"))
296        || (needle.contains("windsurf") && tn.contains("windsurf"))
297        || (needle.contains("codex") && tn.contains("claude"))
298        || (needle.contains("zed") && tn.contains("zed"))
299        || (needle.contains("copilot") && tn.contains("copilot"))
300        || (needle.contains("jetbrains") && tn.contains("jetbrains"))
301        || (needle.contains("kiro") && tn.contains("kiro"))
302        || (needle.contains("gemini") && tn.contains("gemini"))
303        || (needle == "opencode" && tn.contains("opencode"))
304        || (needle == "cline" && tn.contains("cline"))
305        || (needle == "roo" && tn.contains("roo"))
306        || (needle == "amp" && tn.contains("amp"))
307        || (needle == "trae" && tn.contains("trae"))
308        || (needle == "amazonq" && tn.contains("amazon"))
309        || (needle == "pi" && tn.contains("pi coding"))
310        || (needle == "crush" && tn.contains("crush"))
311        || (needle == "verdent" && tn.contains("verdent"))
312        || (needle == "continue" && tn.contains("continue"))
313        || (needle == "qwen" && tn.contains("qwen"))
314        || (needle == "antigravity" && tn.contains("antigravity"))
315        || (needle == "augment" && tn.contains("augment"))
316        || (needle == "openclaw" && tn.contains("openclaw"))
317        || (needle == "grok" && tn.contains("grok"))
318        || (needle == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
319}
320
321/// Check if the rules file for a given MCP client is up-to-date.
322pub fn check_rules_freshness(client_name: &str) -> Option<String> {
323    let home = dirs::home_dir()?;
324    let injection = crate::core::config::Config::load().rules_injection_effective();
325    if injection == crate::core::config::RulesInjection::Off {
326        return None;
327    }
328    let targets = build_rules_targets(&home, injection);
329
330    let matched: Vec<&RulesTarget> = targets
331        .iter()
332        .filter(|t| match_agent_name(client_name, t.name))
333        .collect();
334
335    if matched.is_empty() {
336        return None;
337    }
338
339    for target in &matched {
340        if !target.path.exists() {
341            continue;
342        }
343        let content = std::fs::read_to_string(&target.path).ok()?;
344        let file = RulesFile::parse(&content);
345        if file.has_content() && !file.is_current() {
346            return Some(format!(
347                "[RULES OUTDATED] Your {} rules were written by an older lean-ctx version. \
348                 Re-read your rules file ({}) or run `lean-ctx setup` to update, \
349                 then start a new session for full compatibility.",
350                target.name,
351                target.path.display()
352            ));
353        }
354    }
355
356    None
357}
358
359pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
360    let injection = crate::core::config::Config::load().rules_injection_effective();
361    let targets = build_rules_targets(home, injection);
362    let mut out = Vec::new();
363
364    for target in &targets {
365        let detected = is_tool_detected(target, home);
366        let path = target.path.to_string_lossy().to_string();
367
368        let state = if !detected {
369            "not_detected".to_string()
370        } else if !target.path.exists() {
371            "missing".to_string()
372        } else {
373            match std::fs::read_to_string(&target.path) {
374                Ok(content) => {
375                    let file = RulesFile::parse(&content);
376                    if file.has_content() {
377                        if file.is_current() {
378                            "up_to_date".to_string()
379                        } else {
380                            "outdated".to_string()
381                        }
382                    } else {
383                        "present_without_marker".to_string()
384                    }
385                }
386                Err(_) => "read_error".to_string(),
387            }
388        };
389
390        out.push(RulesTargetStatus {
391            name: target.name.to_string(),
392            detected,
393            path,
394            state,
395            note: None,
396        });
397    }
398
399    out
400}