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
174 let bak_path = target.path.with_extension(format!(
175 "{}.bak",
176 target
177 .path
178 .extension()
179 .and_then(|e| e.to_str())
180 .unwrap_or("")
181 ));
182 let bak_existed_before = bak_path.exists();
183 let bak_mtime_before = bak_existed_before
184 .then(|| {
185 std::fs::metadata(&bak_path)
186 .ok()
187 .and_then(|m| m.modified().ok())
188 })
189 .flatten();
190
191 match inject_rules(target) {
192 Ok(RulesResult::Updated) => {
193 result.updated.push(target.name.to_string());
194 let bak_is_new = if bak_existed_before {
195 std::fs::metadata(&bak_path)
196 .ok()
197 .and_then(|m| m.modified().ok())
198 != bak_mtime_before
199 } else {
200 bak_path.exists()
201 };
202 if bak_is_new {
203 result
204 .backed_up
205 .push(bak_path.to_string_lossy().to_string());
206 }
207 }
208 Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
209 Err(e) => result.errors.push(format!("{}: {e}", target.name)),
210 }
211 }
212
213 result
214}
215
216pub fn inject_rules_for_agent(home: &std::path::Path, agent_key: &str) -> InjectResult {
218 let cfg = crate::core::config::Config::load();
219 if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
220 return InjectResult::default();
221 }
222 if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
223 return InjectResult::default();
224 }
225
226 let targets = build_rules_targets(home, cfg.rules_injection_effective());
227 let mut result = InjectResult::default();
228
229 for target in &targets {
230 if !match_agent_name(agent_key, target.name) {
231 continue;
232 }
233
234 let bak_path = target.path.with_extension(format!(
235 "{}.bak",
236 target
237 .path
238 .extension()
239 .and_then(|e| e.to_str())
240 .unwrap_or("")
241 ));
242 let bak_existed_before = bak_path.exists();
243
244 match inject_rules(target) {
245 Ok(RulesResult::Updated) => {
246 result.updated.push(target.name.to_string());
247 if !bak_existed_before && bak_path.exists() {
248 result
249 .backed_up
250 .push(bak_path.to_string_lossy().to_string());
251 }
252 }
253 Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
254 Err(e) => result.errors.push(format!("{}: {e}", target.name)),
255 }
256 }
257
258 result
259}
260
261#[must_use]
264pub fn any_rules_marker_present(home: &std::path::Path) -> bool {
265 use crate::core::config::RulesInjection;
266 let mut seen = std::collections::HashSet::new();
267 for injection in [RulesInjection::Shared, RulesInjection::Dedicated] {
268 for target in build_rules_targets(home, injection) {
269 if !seen.insert(target.path.clone()) {
270 continue;
271 }
272 if let Ok(content) = std::fs::read_to_string(&target.path)
273 && RulesFile::parse(&content).has_content()
274 {
275 return true;
276 }
277 }
278 }
279 false
280}
281
282fn match_agent_name(cli_key: &str, target_name: &str) -> bool {
283 let needle = cli_key.to_lowercase();
284 let tn = target_name.to_lowercase();
285 needle.contains(&tn)
286 || tn.contains(&needle)
287 || (needle.contains("cursor") && tn.contains("cursor"))
288 || (needle.contains("claude") && tn.contains("claude"))
289 || (needle.contains("codebuddy") && tn.contains("codebuddy"))
290 || (needle.contains("windsurf") && tn.contains("windsurf"))
291 || (needle.contains("codex") && tn.contains("claude"))
292 || (needle.contains("zed") && tn.contains("zed"))
293 || (needle.contains("copilot") && tn.contains("copilot"))
294 || (needle.contains("jetbrains") && tn.contains("jetbrains"))
295 || (needle.contains("kiro") && tn.contains("kiro"))
296 || (needle.contains("gemini") && tn.contains("gemini"))
297 || (needle == "opencode" && tn.contains("opencode"))
298 || (needle == "cline" && tn.contains("cline"))
299 || (needle == "roo" && tn.contains("roo"))
300 || (needle == "amp" && tn.contains("amp"))
301 || (needle == "trae" && tn.contains("trae"))
302 || (needle == "amazonq" && tn.contains("amazon"))
303 || (needle == "pi" && tn.contains("pi coding"))
304 || (needle == "crush" && tn.contains("crush"))
305 || (needle == "verdent" && tn.contains("verdent"))
306 || (needle == "continue" && tn.contains("continue"))
307 || (needle == "qwen" && tn.contains("qwen"))
308 || (needle == "antigravity" && tn.contains("antigravity"))
309 || (needle == "augment" && tn.contains("augment"))
310 || (needle == "openclaw" && tn.contains("openclaw"))
311 || (needle == "grok" && tn.contains("grok"))
312 || (needle == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
313}
314
315pub fn check_rules_freshness(client_name: &str) -> Option<String> {
317 let home = dirs::home_dir()?;
318 let injection = crate::core::config::Config::load().rules_injection_effective();
319 if injection == crate::core::config::RulesInjection::Off {
320 return None;
321 }
322 let targets = build_rules_targets(&home, injection);
323
324 let matched: Vec<&RulesTarget> = targets
325 .iter()
326 .filter(|t| match_agent_name(client_name, t.name))
327 .collect();
328
329 if matched.is_empty() {
330 return None;
331 }
332
333 for target in &matched {
334 if !target.path.exists() {
335 continue;
336 }
337 let content = std::fs::read_to_string(&target.path).ok()?;
338 let file = RulesFile::parse(&content);
339 if file.has_content() && !file.is_current() {
340 return Some(format!(
341 "[RULES OUTDATED] Your {} rules were written by an older lean-ctx version. \
342 Re-read your rules file ({}) or run `lean-ctx setup` to update, \
343 then start a new session for full compatibility.",
344 target.name,
345 target.path.display()
346 ));
347 }
348 }
349
350 None
351}
352
353pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
354 let injection = crate::core::config::Config::load().rules_injection_effective();
355 let targets = build_rules_targets(home, injection);
356 let mut out = Vec::new();
357
358 for target in &targets {
359 let detected = is_tool_detected(target, home);
360 let path = target.path.to_string_lossy().to_string();
361
362 let state = if !detected {
363 "not_detected".to_string()
364 } else if !target.path.exists() {
365 "missing".to_string()
366 } else {
367 match std::fs::read_to_string(&target.path) {
368 Ok(content) => {
369 let file = RulesFile::parse(&content);
370 if file.has_content() {
371 if file.is_current() {
372 "up_to_date".to_string()
373 } else {
374 "outdated".to_string()
375 }
376 } else {
377 "present_without_marker".to_string()
378 }
379 }
380 Err(_) => "read_error".to_string(),
381 }
382 };
383
384 out.push(RulesTargetStatus {
385 name: target.name.to_string(),
386 detected,
387 path,
388 state,
389 note: None,
390 });
391 }
392
393 out
394}