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