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
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10const MARKER: &str = "# lean-ctx — Context Engineering Layer";
11const END_MARKER: &str = "<!-- /lean-ctx -->";
12const RULES_VERSION: &str = "lean-ctx-rules-v12";
13
14pub const RULES_MARKER: &str = MARKER;
15pub const RULES_END_MARKER: &str = END_MARKER;
16pub const RULES_VERSION_STR: &str = RULES_VERSION;
17
18mod content;
19mod detect;
20mod skills;
21mod targets;
22#[cfg(test)]
23mod tests;
24mod write;
25
26pub use content::{
27    GEMINI_DEDICATED_CONTEXT_FILENAME, canonical_rules_block, dedicated_session_summary,
28    gemini_dedicated_rules_path, opencode_dedicated_rules_path, rules_dedicated_markdown,
29    rules_shared_content,
30};
31pub use skills::{install_all_skills, install_skill_for_agent};
32
33use detect::is_tool_detected;
34use targets::build_rules_targets;
35use write::inject_rules;
36
37// ---------------------------------------------------------------------------
38
39struct RulesTarget {
40    name: &'static str,
41    path: PathBuf,
42    format: RulesFormat,
43}
44
45enum RulesFormat {
46    SharedMarkdown,
47    DedicatedMarkdown,
48    CursorMdc,
49}
50
51#[derive(Debug, Default)]
52pub struct InjectResult {
53    pub injected: Vec<String>,
54    pub updated: Vec<String>,
55    pub already: Vec<String>,
56    pub errors: Vec<String>,
57    pub backed_up: Vec<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct RulesTargetStatus {
62    pub name: String,
63    pub detected: bool,
64    pub path: String,
65    pub state: String,
66    pub note: Option<String>,
67}
68
69// ---------------------------------------------------------------------------
70// Injection logic
71// ---------------------------------------------------------------------------
72
73enum RulesResult {
74    Injected,
75    Updated,
76    AlreadyPresent,
77}
78
79pub fn inject_all_rules(home: &std::path::Path) -> InjectResult {
80    let cfg = crate::core::config::Config::load();
81    if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
82        return InjectResult::default();
83    }
84    // `Off`: the host supplies its own steering (or this is a phase-isolated /
85    // non-caching harness) — write no rules file at all (#361).
86    if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
87        return InjectResult::default();
88    }
89
90    let targets = build_rules_targets(home, cfg.rules_injection_effective());
91
92    let mut result = InjectResult::default();
93
94    for target in &targets {
95        if !is_tool_detected(target, home) {
96            continue;
97        }
98
99        let bak_path = target.path.with_extension(format!(
100            "{}.bak",
101            target
102                .path
103                .extension()
104                .and_then(|e| e.to_str())
105                .unwrap_or("")
106        ));
107        let bak_existed_before = bak_path.exists();
108        let bak_mtime_before = bak_existed_before
109            .then(|| {
110                std::fs::metadata(&bak_path)
111                    .ok()
112                    .and_then(|m| m.modified().ok())
113            })
114            .flatten();
115
116        match inject_rules(target) {
117            Ok(RulesResult::Injected) => result.injected.push(target.name.to_string()),
118            Ok(RulesResult::Updated) => {
119                result.updated.push(target.name.to_string());
120                let bak_is_new = if bak_existed_before {
121                    std::fs::metadata(&bak_path)
122                        .ok()
123                        .and_then(|m| m.modified().ok())
124                        != bak_mtime_before
125                } else {
126                    bak_path.exists()
127                };
128                if bak_is_new {
129                    result
130                        .backed_up
131                        .push(bak_path.to_string_lossy().to_string());
132                }
133            }
134            Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
135            Err(e) => result.errors.push(format!("{}: {e}", target.name)),
136        }
137    }
138
139    result
140}
141
142/// Inject global rules for a single agent (by CLI key like "opencode", "cursor", etc.).
143/// Used by `init --agent` to ensure global rules are written alongside MCP config.
144pub fn inject_rules_for_agent(home: &std::path::Path, agent_key: &str) -> InjectResult {
145    let cfg = crate::core::config::Config::load();
146    if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
147        return InjectResult::default();
148    }
149    // `Off`: skip rule-file injection entirely (host-supplied workflow or
150    // phase-isolated / non-caching harness, #361).
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    let mut result = InjectResult::default();
157
158    for target in &targets {
159        if !match_agent_name(agent_key, target.name) {
160            continue;
161        }
162
163        let bak_path = target.path.with_extension(format!(
164            "{}.bak",
165            target
166                .path
167                .extension()
168                .and_then(|e| e.to_str())
169                .unwrap_or("")
170        ));
171        let bak_existed_before = bak_path.exists();
172
173        match inject_rules(target) {
174            Ok(RulesResult::Injected) => result.injected.push(target.name.to_string()),
175            Ok(RulesResult::Updated) => {
176                result.updated.push(target.name.to_string());
177                if !bak_existed_before && bak_path.exists() {
178                    result
179                        .backed_up
180                        .push(bak_path.to_string_lossy().to_string());
181                }
182            }
183            Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
184            Err(e) => result.errors.push(format!("{}: {e}", target.name)),
185        }
186    }
187
188    result
189}
190
191/// Returns `true` if a lean-ctx rules marker is present in *any* supported
192/// agent's rules file (checking both the shared and dedicated layouts).
193///
194/// Drift-proof by construction: the path catalog is derived from
195/// `build_rules_targets`, the same source the injector writes to, so a newly
196/// supported agent is covered automatically. This replaces a hand-maintained
197/// list that silently omitted OpenCode and ~18 other agents (#442) — that gap
198/// made `SetupConfig::should_inject_rules()` report "no rules present" for
199/// OpenCode-only users, so their MCP got registered without the `ctx_*`
200/// guidance that makes the model actually call the tools.
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 std::fs::read_to_string(&target.path).is_ok_and(|content| content.contains(MARKER)) {
211                return true;
212            }
213        }
214    }
215    false
216}
217
218fn match_agent_name(cli_key: &str, target_name: &str) -> bool {
219    let needle = cli_key.to_lowercase();
220    let tn = target_name.to_lowercase();
221    needle.contains(&tn)
222        || tn.contains(&needle)
223        || (needle.contains("cursor") && tn.contains("cursor"))
224        || (needle.contains("claude") && tn.contains("claude"))
225        || (needle.contains("codebuddy") && tn.contains("codebuddy"))
226        || (needle.contains("windsurf") && tn.contains("windsurf"))
227        || (needle.contains("codex") && tn.contains("claude"))
228        || (needle.contains("zed") && tn.contains("zed"))
229        || (needle.contains("copilot") && tn.contains("copilot"))
230        || (needle.contains("jetbrains") && tn.contains("jetbrains"))
231        || (needle.contains("kiro") && tn.contains("kiro"))
232        || (needle.contains("gemini") && tn.contains("gemini"))
233        || (needle == "opencode" && tn.contains("opencode"))
234        || (needle == "cline" && tn.contains("cline"))
235        || (needle == "roo" && tn.contains("roo"))
236        || (needle == "amp" && tn.contains("amp"))
237        || (needle == "trae" && tn.contains("trae"))
238        || (needle == "amazonq" && tn.contains("amazon"))
239        || (needle == "pi" && tn.contains("pi coding"))
240        || (needle == "crush" && tn.contains("crush"))
241        || (needle == "verdent" && tn.contains("verdent"))
242        || (needle == "continue" && tn.contains("continue"))
243        || (needle == "qwen" && tn.contains("qwen"))
244        || (needle == "antigravity" && tn.contains("antigravity"))
245        || (needle == "augment" && tn.contains("augment"))
246        || (needle == "openclaw" && tn.contains("openclaw"))
247        || (needle == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
248}
249
250/// Check if the rules file for a given MCP client is up-to-date.
251/// Returns `Some(message)` if rules are stale/missing, `None` if current.
252pub fn check_rules_freshness(client_name: &str) -> Option<String> {
253    let home = dirs::home_dir()?;
254    let injection = crate::core::config::Config::load().rules_injection_effective();
255    // `Off`: lean-ctx does not manage a rules file, so it never nags about
256    // staleness (#361).
257    if injection == crate::core::config::RulesInjection::Off {
258        return None;
259    }
260    let targets = build_rules_targets(&home, injection);
261
262    let matched: Vec<&RulesTarget> = targets
263        .iter()
264        .filter(|t| match_agent_name(client_name, t.name))
265        .collect();
266
267    if matched.is_empty() {
268        return None;
269    }
270
271    for target in &matched {
272        if !target.path.exists() {
273            continue;
274        }
275        let content = std::fs::read_to_string(&target.path).ok()?;
276        if content.contains(MARKER) && !content.contains(RULES_VERSION) {
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                    if content.contains(MARKER) {
307                        if content.contains(RULES_VERSION) {
308                            "up_to_date".to_string()
309                        } else {
310                            "outdated".to_string()
311                        }
312                    } else {
313                        "present_without_marker".to_string()
314                    }
315                }
316                Err(_) => "read_error".to_string(),
317            }
318        };
319
320        out.push(RulesTargetStatus {
321            name: target.name.to_string(),
322            detected,
323            path,
324            state,
325            note: None,
326        });
327    }
328
329    out
330}