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
55use detect::is_tool_detected;
56use targets::build_rules_targets;
57use write::inject_rules;
58
59struct RulesTarget {
60 name: &'static str,
61 path: PathBuf,
62 format: RulesFormat,
63}
64
65enum RulesFormat {
66 SharedMarkdown,
67 DedicatedMarkdown,
68 CursorMdc,
69}
70
71#[derive(Debug, Default)]
72pub struct InjectResult {
73 pub injected: Vec<String>,
74 pub updated: Vec<String>,
75 pub already: Vec<String>,
76 pub errors: Vec<String>,
77 pub backed_up: Vec<String>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct RulesTargetStatus {
82 pub name: String,
83 pub detected: bool,
84 pub path: String,
85 pub state: String,
86 pub note: Option<String>,
87}
88
89enum RulesResult {
90 Updated,
91 AlreadyPresent,
92}
93
94pub fn inject_all_rules(home: &std::path::Path) -> InjectResult {
95 let cfg = crate::core::config::Config::load();
96 if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
97 return InjectResult::default();
98 }
99 if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
100 return InjectResult::default();
101 }
102
103 let targets = build_rules_targets(home, cfg.rules_injection_effective());
104
105 let mut result = InjectResult::default();
106
107 for target in &targets {
108 if !is_tool_detected(target, home) {
109 continue;
110 }
111
112 let bak_path = target.path.with_extension(format!(
113 "{}.bak",
114 target
115 .path
116 .extension()
117 .and_then(|e| e.to_str())
118 .unwrap_or("")
119 ));
120 let bak_existed_before = bak_path.exists();
121 let bak_mtime_before = bak_existed_before
122 .then(|| {
123 std::fs::metadata(&bak_path)
124 .ok()
125 .and_then(|m| m.modified().ok())
126 })
127 .flatten();
128
129 match inject_rules(target) {
130 Ok(RulesResult::Updated) => {
131 result.updated.push(target.name.to_string());
132 let bak_is_new = if bak_existed_before {
133 std::fs::metadata(&bak_path)
134 .ok()
135 .and_then(|m| m.modified().ok())
136 != bak_mtime_before
137 } else {
138 bak_path.exists()
139 };
140 if bak_is_new {
141 result
142 .backed_up
143 .push(bak_path.to_string_lossy().to_string());
144 }
145 }
146 Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
147 Err(e) => result.errors.push(format!("{}: {e}", target.name)),
148 }
149 }
150
151 result
152}
153
154pub fn inject_rules_for_agent(home: &std::path::Path, agent_key: &str) -> InjectResult {
156 let cfg = crate::core::config::Config::load();
157 if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
158 return InjectResult::default();
159 }
160 if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
161 return InjectResult::default();
162 }
163
164 let targets = build_rules_targets(home, cfg.rules_injection_effective());
165 let mut result = InjectResult::default();
166
167 for target in &targets {
168 if !match_agent_name(agent_key, target.name) {
169 continue;
170 }
171
172 let bak_path = target.path.with_extension(format!(
173 "{}.bak",
174 target
175 .path
176 .extension()
177 .and_then(|e| e.to_str())
178 .unwrap_or("")
179 ));
180 let bak_existed_before = bak_path.exists();
181
182 match inject_rules(target) {
183 Ok(RulesResult::Updated) => {
184 result.updated.push(target.name.to_string());
185 if !bak_existed_before && bak_path.exists() {
186 result
187 .backed_up
188 .push(bak_path.to_string_lossy().to_string());
189 }
190 }
191 Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
192 Err(e) => result.errors.push(format!("{}: {e}", target.name)),
193 }
194 }
195
196 result
197}
198
199#[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 let Ok(content) = std::fs::read_to_string(&target.path)
211 && RulesFile::parse(&content).has_content()
212 {
213 return true;
214 }
215 }
216 }
217 false
218}
219
220fn match_agent_name(cli_key: &str, target_name: &str) -> bool {
221 let needle = cli_key.to_lowercase();
222 let tn = target_name.to_lowercase();
223 needle.contains(&tn)
224 || tn.contains(&needle)
225 || (needle.contains("cursor") && tn.contains("cursor"))
226 || (needle.contains("claude") && tn.contains("claude"))
227 || (needle.contains("codebuddy") && tn.contains("codebuddy"))
228 || (needle.contains("windsurf") && tn.contains("windsurf"))
229 || (needle.contains("codex") && tn.contains("claude"))
230 || (needle.contains("zed") && tn.contains("zed"))
231 || (needle.contains("copilot") && tn.contains("copilot"))
232 || (needle.contains("jetbrains") && tn.contains("jetbrains"))
233 || (needle.contains("kiro") && tn.contains("kiro"))
234 || (needle.contains("gemini") && tn.contains("gemini"))
235 || (needle == "opencode" && tn.contains("opencode"))
236 || (needle == "cline" && tn.contains("cline"))
237 || (needle == "roo" && tn.contains("roo"))
238 || (needle == "amp" && tn.contains("amp"))
239 || (needle == "trae" && tn.contains("trae"))
240 || (needle == "amazonq" && tn.contains("amazon"))
241 || (needle == "pi" && tn.contains("pi coding"))
242 || (needle == "crush" && tn.contains("crush"))
243 || (needle == "verdent" && tn.contains("verdent"))
244 || (needle == "continue" && tn.contains("continue"))
245 || (needle == "qwen" && tn.contains("qwen"))
246 || (needle == "antigravity" && tn.contains("antigravity"))
247 || (needle == "augment" && tn.contains("augment"))
248 || (needle == "openclaw" && tn.contains("openclaw"))
249 || (needle == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
250}
251
252pub fn check_rules_freshness(client_name: &str) -> Option<String> {
254 let home = dirs::home_dir()?;
255 let injection = crate::core::config::Config::load().rules_injection_effective();
256 if injection == crate::core::config::RulesInjection::Off {
257 return None;
258 }
259 let targets = build_rules_targets(&home, injection);
260
261 let matched: Vec<&RulesTarget> = targets
262 .iter()
263 .filter(|t| match_agent_name(client_name, t.name))
264 .collect();
265
266 if matched.is_empty() {
267 return None;
268 }
269
270 for target in &matched {
271 if !target.path.exists() {
272 continue;
273 }
274 let content = std::fs::read_to_string(&target.path).ok()?;
275 let file = RulesFile::parse(&content);
276 if file.has_content() && !file.is_current() {
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 let file = RulesFile::parse(&content);
307 if file.has_content() {
308 if file.is_current() {
309 "up_to_date".to_string()
310 } else {
311 "outdated".to_string()
312 }
313 } else {
314 "present_without_marker".to_string()
315 }
316 }
317 Err(_) => "read_error".to_string(),
318 }
319 };
320
321 out.push(RulesTargetStatus {
322 name: target.name.to_string(),
323 detected,
324 path,
325 state,
326 note: None,
327 });
328 }
329
330 out
331}