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