lean_ctx/rules_inject/
mod.rs1use 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
31pub 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 let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
37 crate::core::rules_canonical::render(
38 shadow,
39 crate::core::rules_canonical::Wrapper::Shared,
40 level,
41 &profile,
42 )
43}
44pub fn rules_shared_content() -> String {
45 canonical_rules_block()
46}
47pub fn rules_dedicated_markdown() -> String {
48 let cfg = crate::core::config::Config::load();
49 let shadow = cfg.shadow_mode;
50 let level = crate::core::config::CompressionLevel::effective(&cfg);
51 let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
52 crate::core::rules_canonical::render(
53 shadow,
54 crate::core::rules_canonical::Wrapper::Dedicated,
55 level,
56 &profile,
57 )
58}
59
60pub fn rules_longform_markdown() -> String {
64 let cfg = crate::core::config::Config::load();
65 let shadow = cfg.shadow_mode;
66 let level = crate::core::config::CompressionLevel::effective(&cfg);
67 let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
68 crate::core::rules_canonical::render(
69 shadow,
70 crate::core::rules_canonical::Wrapper::Longform,
71 level,
72 &profile,
73 )
74}
75
76pub fn expected_blocks_by_target(
85 home: &std::path::Path,
86) -> std::collections::HashMap<String, String> {
87 let injection = crate::core::config::Config::load().rules_injection_effective();
88 let cfg = crate::core::config::Config::load();
89 let shadow = cfg.shadow_mode;
90 let level = crate::core::config::CompressionLevel::effective(&cfg);
91 let shared = canonical_rules_block();
92 let dedicated = rules_dedicated_markdown();
93 build_rules_targets(home, injection)
94 .into_iter()
95 .map(|target| {
96 let expected = match target.format {
97 RulesFormat::SharedMarkdown => shared.clone(),
98 RulesFormat::DedicatedMarkdown => dedicated.clone(),
99 RulesFormat::CursorMdc => {
103 let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
104 crate::core::rules_canonical::render(
105 shadow,
106 content::cursor_wrapper_for_mdc(&target.path),
107 level,
108 &profile,
109 )
110 }
111 };
112 (target.name.to_string(), expected)
113 })
114 .collect()
115}
116
117use detect::is_tool_detected;
118use targets::build_rules_targets;
119use write::inject_rules;
120
121struct RulesTarget {
122 name: &'static str,
123 path: PathBuf,
124 format: RulesFormat,
125}
126
127enum RulesFormat {
128 SharedMarkdown,
129 DedicatedMarkdown,
130 CursorMdc,
131}
132
133#[derive(Debug, Default)]
134pub struct InjectResult {
135 pub injected: Vec<String>,
136 pub updated: Vec<String>,
137 pub already: Vec<String>,
138 pub errors: Vec<String>,
139 pub backed_up: Vec<String>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct RulesTargetStatus {
144 pub name: String,
145 pub detected: bool,
146 pub path: String,
147 pub state: String,
148 pub note: Option<String>,
149}
150
151enum RulesResult {
152 Updated,
153 AlreadyPresent,
154}
155
156pub fn inject_all_rules(home: &std::path::Path) -> InjectResult {
157 let cfg = crate::core::config::Config::load();
158 if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
159 return InjectResult::default();
160 }
161 if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
162 return InjectResult::default();
163 }
164
165 let targets = build_rules_targets(home, cfg.rules_injection_effective());
166
167 let mut result = InjectResult::default();
168
169 for target in &targets {
170 if !is_tool_detected(target, home) {
171 continue;
172 }
173 if !detect::is_mcp_configured(target, home) {
177 continue;
178 }
179
180 let bak_path = target.path.with_extension(format!(
181 "{}.bak",
182 target
183 .path
184 .extension()
185 .and_then(|e| e.to_str())
186 .unwrap_or("")
187 ));
188 let bak_existed_before = bak_path.exists();
189 let bak_mtime_before = bak_existed_before
190 .then(|| {
191 std::fs::metadata(&bak_path)
192 .ok()
193 .and_then(|m| m.modified().ok())
194 })
195 .flatten();
196
197 match inject_rules(target) {
198 Ok(RulesResult::Updated) => {
199 result.updated.push(target.name.to_string());
200 let bak_is_new = if bak_existed_before {
201 std::fs::metadata(&bak_path)
202 .ok()
203 .and_then(|m| m.modified().ok())
204 != bak_mtime_before
205 } else {
206 bak_path.exists()
207 };
208 if bak_is_new {
209 result
210 .backed_up
211 .push(bak_path.to_string_lossy().to_string());
212 }
213 }
214 Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
215 Err(e) => result.errors.push(format!("{}: {e}", target.name)),
216 }
217 }
218
219 result
220}
221
222pub fn inject_rules_for_agent(home: &std::path::Path, agent_key: &str) -> InjectResult {
224 let cfg = crate::core::config::Config::load();
225 if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
226 return InjectResult::default();
227 }
228 if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
229 return InjectResult::default();
230 }
231
232 let targets = build_rules_targets(home, cfg.rules_injection_effective());
233 let mut result = InjectResult::default();
234
235 for target in &targets {
236 if !match_agent_name(agent_key, target.name) {
237 continue;
238 }
239
240 let bak_path = target.path.with_extension(format!(
241 "{}.bak",
242 target
243 .path
244 .extension()
245 .and_then(|e| e.to_str())
246 .unwrap_or("")
247 ));
248 let bak_existed_before = bak_path.exists();
249
250 match inject_rules(target) {
251 Ok(RulesResult::Updated) => {
252 result.updated.push(target.name.to_string());
253 if !bak_existed_before && bak_path.exists() {
254 result
255 .backed_up
256 .push(bak_path.to_string_lossy().to_string());
257 }
258 }
259 Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
260 Err(e) => result.errors.push(format!("{}: {e}", target.name)),
261 }
262 }
263
264 result
265}
266
267#[must_use]
270pub fn any_rules_marker_present(home: &std::path::Path) -> bool {
271 use crate::core::config::RulesInjection;
272 let mut seen = std::collections::HashSet::new();
273 for injection in [RulesInjection::Shared, RulesInjection::Dedicated] {
274 for target in build_rules_targets(home, injection) {
275 if !seen.insert(target.path.clone()) {
276 continue;
277 }
278 if let Ok(content) = std::fs::read_to_string(&target.path)
279 && RulesFile::parse(&content).has_content()
280 {
281 return true;
282 }
283 }
284 }
285 false
286}
287
288fn match_agent_name(cli_key: &str, target_name: &str) -> bool {
289 let needle = cli_key.to_lowercase();
290 let tn = target_name.to_lowercase();
291 needle.contains(&tn)
292 || tn.contains(&needle)
293 || (needle.contains("cursor") && tn.contains("cursor"))
294 || (needle.contains("claude") && tn.contains("claude"))
295 || (needle.contains("codebuddy") && tn.contains("codebuddy"))
296 || (needle.contains("windsurf") && tn.contains("windsurf"))
297 || (needle.contains("codex") && tn.contains("claude"))
298 || (needle.contains("zed") && tn.contains("zed"))
299 || (needle.contains("copilot") && tn.contains("copilot"))
300 || (needle.contains("jetbrains") && tn.contains("jetbrains"))
301 || (needle.contains("kiro") && tn.contains("kiro"))
302 || (needle.contains("gemini") && tn.contains("gemini"))
303 || (needle == "opencode" && tn.contains("opencode"))
304 || (needle == "cline" && tn.contains("cline"))
305 || (needle == "roo" && tn.contains("roo"))
306 || (needle == "amp" && tn.contains("amp"))
307 || (needle == "trae" && tn.contains("trae"))
308 || (needle == "amazonq" && tn.contains("amazon"))
309 || (needle == "pi" && tn.contains("pi coding"))
310 || (needle == "crush" && tn.contains("crush"))
311 || (needle == "verdent" && tn.contains("verdent"))
312 || (needle == "continue" && tn.contains("continue"))
313 || (needle == "qwen" && tn.contains("qwen"))
314 || (needle == "antigravity" && tn.contains("antigravity"))
315 || (needle == "augment" && tn.contains("augment"))
316 || (needle == "openclaw" && tn.contains("openclaw"))
317 || (needle == "grok" && tn.contains("grok"))
318 || (needle == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
319}
320
321pub fn check_rules_freshness(client_name: &str) -> Option<String> {
323 let home = dirs::home_dir()?;
324 let injection = crate::core::config::Config::load().rules_injection_effective();
325 if injection == crate::core::config::RulesInjection::Off {
326 return None;
327 }
328 let targets = build_rules_targets(&home, injection);
329
330 let matched: Vec<&RulesTarget> = targets
331 .iter()
332 .filter(|t| match_agent_name(client_name, t.name))
333 .collect();
334
335 if matched.is_empty() {
336 return None;
337 }
338
339 for target in &matched {
340 if !target.path.exists() {
341 continue;
342 }
343 let content = std::fs::read_to_string(&target.path).ok()?;
344 let file = RulesFile::parse(&content);
345 if file.has_content() && !file.is_current() {
346 return Some(format!(
347 "[RULES OUTDATED] Your {} rules were written by an older lean-ctx version. \
348 Re-read your rules file ({}) or run `lean-ctx setup` to update, \
349 then start a new session for full compatibility.",
350 target.name,
351 target.path.display()
352 ));
353 }
354 }
355
356 None
357}
358
359pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
360 let injection = crate::core::config::Config::load().rules_injection_effective();
361 let targets = build_rules_targets(home, injection);
362 let mut out = Vec::new();
363
364 for target in &targets {
365 let detected = is_tool_detected(target, home);
366 let path = target.path.to_string_lossy().to_string();
367
368 let state = if !detected {
369 "not_detected".to_string()
370 } else if !target.path.exists() {
371 "missing".to_string()
372 } else {
373 match std::fs::read_to_string(&target.path) {
374 Ok(content) => {
375 let file = RulesFile::parse(&content);
376 if file.has_content() {
377 if file.is_current() {
378 "up_to_date".to_string()
379 } else {
380 "outdated".to_string()
381 }
382 } else {
383 "present_without_marker".to_string()
384 }
385 }
386 Err(_) => "read_error".to_string(),
387 }
388 };
389
390 out.push(RulesTargetStatus {
391 name: target.name.to_string(),
392 detected,
393 path,
394 state,
395 note: None,
396 });
397 }
398
399 out
400}