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
174        let bak_path = target.path.with_extension(format!(
175            "{}.bak",
176            target
177                .path
178                .extension()
179                .and_then(|e| e.to_str())
180                .unwrap_or("")
181        ));
182        let bak_existed_before = bak_path.exists();
183        let bak_mtime_before = bak_existed_before
184            .then(|| {
185                std::fs::metadata(&bak_path)
186                    .ok()
187                    .and_then(|m| m.modified().ok())
188            })
189            .flatten();
190
191        match inject_rules(target) {
192            Ok(RulesResult::Updated) => {
193                result.updated.push(target.name.to_string());
194                let bak_is_new = if bak_existed_before {
195                    std::fs::metadata(&bak_path)
196                        .ok()
197                        .and_then(|m| m.modified().ok())
198                        != bak_mtime_before
199                } else {
200                    bak_path.exists()
201                };
202                if bak_is_new {
203                    result
204                        .backed_up
205                        .push(bak_path.to_string_lossy().to_string());
206                }
207            }
208            Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
209            Err(e) => result.errors.push(format!("{}: {e}", target.name)),
210        }
211    }
212
213    result
214}
215
216/// Inject global rules for a single agent (by CLI key like "opencode", "cursor", etc.).
217pub fn inject_rules_for_agent(home: &std::path::Path, agent_key: &str) -> InjectResult {
218    let cfg = crate::core::config::Config::load();
219    if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
220        return InjectResult::default();
221    }
222    if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
223        return InjectResult::default();
224    }
225
226    let targets = build_rules_targets(home, cfg.rules_injection_effective());
227    let mut result = InjectResult::default();
228
229    for target in &targets {
230        if !match_agent_name(agent_key, target.name) {
231            continue;
232        }
233
234        let bak_path = target.path.with_extension(format!(
235            "{}.bak",
236            target
237                .path
238                .extension()
239                .and_then(|e| e.to_str())
240                .unwrap_or("")
241        ));
242        let bak_existed_before = bak_path.exists();
243
244        match inject_rules(target) {
245            Ok(RulesResult::Updated) => {
246                result.updated.push(target.name.to_string());
247                if !bak_existed_before && bak_path.exists() {
248                    result
249                        .backed_up
250                        .push(bak_path.to_string_lossy().to_string());
251                }
252            }
253            Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
254            Err(e) => result.errors.push(format!("{}: {e}", target.name)),
255        }
256    }
257
258    result
259}
260
261/// Returns `true` if a lean-ctx rules marker is present in *any* supported
262/// agent's rules file.
263#[must_use]
264pub fn any_rules_marker_present(home: &std::path::Path) -> bool {
265    use crate::core::config::RulesInjection;
266    let mut seen = std::collections::HashSet::new();
267    for injection in [RulesInjection::Shared, RulesInjection::Dedicated] {
268        for target in build_rules_targets(home, injection) {
269            if !seen.insert(target.path.clone()) {
270                continue;
271            }
272            if let Ok(content) = std::fs::read_to_string(&target.path)
273                && RulesFile::parse(&content).has_content()
274            {
275                return true;
276            }
277        }
278    }
279    false
280}
281
282fn match_agent_name(cli_key: &str, target_name: &str) -> bool {
283    let needle = cli_key.to_lowercase();
284    let tn = target_name.to_lowercase();
285    needle.contains(&tn)
286        || tn.contains(&needle)
287        || (needle.contains("cursor") && tn.contains("cursor"))
288        || (needle.contains("claude") && tn.contains("claude"))
289        || (needle.contains("codebuddy") && tn.contains("codebuddy"))
290        || (needle.contains("windsurf") && tn.contains("windsurf"))
291        || (needle.contains("codex") && tn.contains("claude"))
292        || (needle.contains("zed") && tn.contains("zed"))
293        || (needle.contains("copilot") && tn.contains("copilot"))
294        || (needle.contains("jetbrains") && tn.contains("jetbrains"))
295        || (needle.contains("kiro") && tn.contains("kiro"))
296        || (needle.contains("gemini") && tn.contains("gemini"))
297        || (needle == "opencode" && tn.contains("opencode"))
298        || (needle == "cline" && tn.contains("cline"))
299        || (needle == "roo" && tn.contains("roo"))
300        || (needle == "amp" && tn.contains("amp"))
301        || (needle == "trae" && tn.contains("trae"))
302        || (needle == "amazonq" && tn.contains("amazon"))
303        || (needle == "pi" && tn.contains("pi coding"))
304        || (needle == "crush" && tn.contains("crush"))
305        || (needle == "verdent" && tn.contains("verdent"))
306        || (needle == "continue" && tn.contains("continue"))
307        || (needle == "qwen" && tn.contains("qwen"))
308        || (needle == "antigravity" && tn.contains("antigravity"))
309        || (needle == "augment" && tn.contains("augment"))
310        || (needle == "openclaw" && tn.contains("openclaw"))
311        || (needle == "grok" && tn.contains("grok"))
312        || (needle == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
313}
314
315/// Check if the rules file for a given MCP client is up-to-date.
316pub fn check_rules_freshness(client_name: &str) -> Option<String> {
317    let home = dirs::home_dir()?;
318    let injection = crate::core::config::Config::load().rules_injection_effective();
319    if injection == crate::core::config::RulesInjection::Off {
320        return None;
321    }
322    let targets = build_rules_targets(&home, injection);
323
324    let matched: Vec<&RulesTarget> = targets
325        .iter()
326        .filter(|t| match_agent_name(client_name, t.name))
327        .collect();
328
329    if matched.is_empty() {
330        return None;
331    }
332
333    for target in &matched {
334        if !target.path.exists() {
335            continue;
336        }
337        let content = std::fs::read_to_string(&target.path).ok()?;
338        let file = RulesFile::parse(&content);
339        if file.has_content() && !file.is_current() {
340            return Some(format!(
341                "[RULES OUTDATED] Your {} rules were written by an older lean-ctx version. \
342                 Re-read your rules file ({}) or run `lean-ctx setup` to update, \
343                 then start a new session for full compatibility.",
344                target.name,
345                target.path.display()
346            ));
347        }
348    }
349
350    None
351}
352
353pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
354    let injection = crate::core::config::Config::load().rules_injection_effective();
355    let targets = build_rules_targets(home, injection);
356    let mut out = Vec::new();
357
358    for target in &targets {
359        let detected = is_tool_detected(target, home);
360        let path = target.path.to_string_lossy().to_string();
361
362        let state = if !detected {
363            "not_detected".to_string()
364        } else if !target.path.exists() {
365            "missing".to_string()
366        } else {
367            match std::fs::read_to_string(&target.path) {
368                Ok(content) => {
369                    let file = RulesFile::parse(&content);
370                    if file.has_content() {
371                        if file.is_current() {
372                            "up_to_date".to_string()
373                        } else {
374                            "outdated".to_string()
375                        }
376                    } else {
377                        "present_without_marker".to_string()
378                    }
379                }
380                Err(_) => "read_error".to_string(),
381            }
382        };
383
384        out.push(RulesTargetStatus {
385            name: target.name.to_string(),
386            detected,
387            path,
388            state,
389            note: None,
390        });
391    }
392
393    out
394}