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