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