lean_ctx/rules_inject/
mod.rs1use 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
37struct 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
69enum 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 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
142pub 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 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#[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
250pub 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 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}