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
55use detect::is_tool_detected;
56use targets::build_rules_targets;
57use write::inject_rules;
58
59struct RulesTarget {
60    name: &'static str,
61    path: PathBuf,
62    format: RulesFormat,
63}
64
65enum RulesFormat {
66    SharedMarkdown,
67    DedicatedMarkdown,
68    CursorMdc,
69}
70
71#[derive(Debug, Default)]
72pub struct InjectResult {
73    pub injected: Vec<String>,
74    pub updated: Vec<String>,
75    pub already: Vec<String>,
76    pub errors: Vec<String>,
77    pub backed_up: Vec<String>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct RulesTargetStatus {
82    pub name: String,
83    pub detected: bool,
84    pub path: String,
85    pub state: String,
86    pub note: Option<String>,
87}
88
89enum RulesResult {
90    Updated,
91    AlreadyPresent,
92}
93
94pub fn inject_all_rules(home: &std::path::Path) -> InjectResult {
95    let cfg = crate::core::config::Config::load();
96    if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
97        return InjectResult::default();
98    }
99    if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
100        return InjectResult::default();
101    }
102
103    let targets = build_rules_targets(home, cfg.rules_injection_effective());
104
105    let mut result = InjectResult::default();
106
107    for target in &targets {
108        if !is_tool_detected(target, home) {
109            continue;
110        }
111
112        let bak_path = target.path.with_extension(format!(
113            "{}.bak",
114            target
115                .path
116                .extension()
117                .and_then(|e| e.to_str())
118                .unwrap_or("")
119        ));
120        let bak_existed_before = bak_path.exists();
121        let bak_mtime_before = bak_existed_before
122            .then(|| {
123                std::fs::metadata(&bak_path)
124                    .ok()
125                    .and_then(|m| m.modified().ok())
126            })
127            .flatten();
128
129        match inject_rules(target) {
130            Ok(RulesResult::Updated) => {
131                result.updated.push(target.name.to_string());
132                let bak_is_new = if bak_existed_before {
133                    std::fs::metadata(&bak_path)
134                        .ok()
135                        .and_then(|m| m.modified().ok())
136                        != bak_mtime_before
137                } else {
138                    bak_path.exists()
139                };
140                if bak_is_new {
141                    result
142                        .backed_up
143                        .push(bak_path.to_string_lossy().to_string());
144                }
145            }
146            Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
147            Err(e) => result.errors.push(format!("{}: {e}", target.name)),
148        }
149    }
150
151    result
152}
153
154/// Inject global rules for a single agent (by CLI key like "opencode", "cursor", etc.).
155pub fn inject_rules_for_agent(home: &std::path::Path, agent_key: &str) -> InjectResult {
156    let cfg = crate::core::config::Config::load();
157    if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
158        return InjectResult::default();
159    }
160    if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
161        return InjectResult::default();
162    }
163
164    let targets = build_rules_targets(home, cfg.rules_injection_effective());
165    let mut result = InjectResult::default();
166
167    for target in &targets {
168        if !match_agent_name(agent_key, target.name) {
169            continue;
170        }
171
172        let bak_path = target.path.with_extension(format!(
173            "{}.bak",
174            target
175                .path
176                .extension()
177                .and_then(|e| e.to_str())
178                .unwrap_or("")
179        ));
180        let bak_existed_before = bak_path.exists();
181
182        match inject_rules(target) {
183            Ok(RulesResult::Updated) => {
184                result.updated.push(target.name.to_string());
185                if !bak_existed_before && bak_path.exists() {
186                    result
187                        .backed_up
188                        .push(bak_path.to_string_lossy().to_string());
189                }
190            }
191            Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
192            Err(e) => result.errors.push(format!("{}: {e}", target.name)),
193        }
194    }
195
196    result
197}
198
199/// Returns `true` if a lean-ctx rules marker is present in *any* supported
200/// agent's rules file.
201#[must_use]
202pub fn any_rules_marker_present(home: &std::path::Path) -> bool {
203    use crate::core::config::RulesInjection;
204    let mut seen = std::collections::HashSet::new();
205    for injection in [RulesInjection::Shared, RulesInjection::Dedicated] {
206        for target in build_rules_targets(home, injection) {
207            if !seen.insert(target.path.clone()) {
208                continue;
209            }
210            if let Ok(content) = std::fs::read_to_string(&target.path)
211                && RulesFile::parse(&content).has_content()
212            {
213                return true;
214            }
215        }
216    }
217    false
218}
219
220fn match_agent_name(cli_key: &str, target_name: &str) -> bool {
221    let needle = cli_key.to_lowercase();
222    let tn = target_name.to_lowercase();
223    needle.contains(&tn)
224        || tn.contains(&needle)
225        || (needle.contains("cursor") && tn.contains("cursor"))
226        || (needle.contains("claude") && tn.contains("claude"))
227        || (needle.contains("codebuddy") && tn.contains("codebuddy"))
228        || (needle.contains("windsurf") && tn.contains("windsurf"))
229        || (needle.contains("codex") && tn.contains("claude"))
230        || (needle.contains("zed") && tn.contains("zed"))
231        || (needle.contains("copilot") && tn.contains("copilot"))
232        || (needle.contains("jetbrains") && tn.contains("jetbrains"))
233        || (needle.contains("kiro") && tn.contains("kiro"))
234        || (needle.contains("gemini") && tn.contains("gemini"))
235        || (needle == "opencode" && tn.contains("opencode"))
236        || (needle == "cline" && tn.contains("cline"))
237        || (needle == "roo" && tn.contains("roo"))
238        || (needle == "amp" && tn.contains("amp"))
239        || (needle == "trae" && tn.contains("trae"))
240        || (needle == "amazonq" && tn.contains("amazon"))
241        || (needle == "pi" && tn.contains("pi coding"))
242        || (needle == "crush" && tn.contains("crush"))
243        || (needle == "verdent" && tn.contains("verdent"))
244        || (needle == "continue" && tn.contains("continue"))
245        || (needle == "qwen" && tn.contains("qwen"))
246        || (needle == "antigravity" && tn.contains("antigravity"))
247        || (needle == "augment" && tn.contains("augment"))
248        || (needle == "openclaw" && tn.contains("openclaw"))
249        || (needle == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
250}
251
252/// Check if the rules file for a given MCP client is up-to-date.
253pub fn check_rules_freshness(client_name: &str) -> Option<String> {
254    let home = dirs::home_dir()?;
255    let injection = crate::core::config::Config::load().rules_injection_effective();
256    if injection == crate::core::config::RulesInjection::Off {
257        return None;
258    }
259    let targets = build_rules_targets(&home, injection);
260
261    let matched: Vec<&RulesTarget> = targets
262        .iter()
263        .filter(|t| match_agent_name(client_name, t.name))
264        .collect();
265
266    if matched.is_empty() {
267        return None;
268    }
269
270    for target in &matched {
271        if !target.path.exists() {
272            continue;
273        }
274        let content = std::fs::read_to_string(&target.path).ok()?;
275        let file = RulesFile::parse(&content);
276        if file.has_content() && !file.is_current() {
277            return Some(format!(
278                "[RULES OUTDATED] Your {} rules were written by an older lean-ctx version. \
279                 Re-read your rules file ({}) or run `lean-ctx setup` to update, \
280                 then start a new session for full compatibility.",
281                target.name,
282                target.path.display()
283            ));
284        }
285    }
286
287    None
288}
289
290pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
291    let injection = crate::core::config::Config::load().rules_injection_effective();
292    let targets = build_rules_targets(home, injection);
293    let mut out = Vec::new();
294
295    for target in &targets {
296        let detected = is_tool_detected(target, home);
297        let path = target.path.to_string_lossy().to_string();
298
299        let state = if !detected {
300            "not_detected".to_string()
301        } else if !target.path.exists() {
302            "missing".to_string()
303        } else {
304            match std::fs::read_to_string(&target.path) {
305                Ok(content) => {
306                    let file = RulesFile::parse(&content);
307                    if file.has_content() {
308                        if file.is_current() {
309                            "up_to_date".to_string()
310                        } else {
311                            "outdated".to_string()
312                        }
313                    } else {
314                        "present_without_marker".to_string()
315                    }
316                }
317                Err(_) => "read_error".to_string(),
318            }
319        };
320
321        out.push(RulesTargetStatus {
322            name: target.name.to_string(),
323            detected,
324            path,
325            state,
326            note: None,
327        });
328    }
329
330    out
331}