lean_ctx/core/
rules_channel.rs1use std::path::Path;
17
18pub use crate::core::rules_canonical::{COMPRESSION_BLOCK_END, COMPRESSION_BLOCK_START};
23
24pub const AGENTS_MD_READERS: &[&str] = &["cursor", "codex"];
28
29pub fn carries_full_rules(content: &str) -> bool {
37 content.contains(crate::core::rules_canonical::START_MARK)
38 || content.contains(COMPRESSION_BLOCK_START)
39}
40
41pub fn is_pointer_only(content: &str) -> bool {
44 content.contains("<!-- lean-ctx") && !carries_full_rules(content)
45}
46
47fn file_has_compression(path: &Path) -> bool {
48 std::fs::read_to_string(path).is_ok_and(|c| c.contains(COMPRESSION_BLOCK_START))
49}
50
51pub fn cursor_compression_covered(home: &Path) -> bool {
54 file_has_compression(&home.join(".cursor/rules/lean-ctx.mdc"))
55}
56
57pub fn cursor_hooks_cover_native_tools(home: &Path) -> bool {
63 cursor_hooks_json_covers(&home.join(".cursor/hooks.json"))
64}
65
66pub fn cursor_hooks_json_covers(hooks_json: &Path) -> bool {
73 let Ok(content) = std::fs::read_to_string(hooks_json) else {
74 return false;
75 };
76 let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
77 return false;
78 };
79 let Some(pre) = v.pointer("/hooks/preToolUse").and_then(|p| p.as_array()) else {
80 return false;
81 };
82 let has_lean_ctx_hook = |suffix: &str| {
83 pre.iter().any(|e| {
84 e.get("command")
85 .and_then(|c| c.as_str())
86 .is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix))
87 })
88 };
89 has_lean_ctx_hook("hook rewrite") && has_lean_ctx_hook("hook redirect")
90}
91
92pub fn client_hook_covered(client_name: &str, home: &Path) -> bool {
98 let lower = client_name.to_lowercase();
99 lower.contains("cursor") && cursor_hooks_cover_native_tools(home)
100}
101
102fn codex_dir(home: &Path) -> std::path::PathBuf {
104 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"))
105}
106
107pub fn codex_present(home: &Path) -> bool {
109 codex_dir(home).exists()
110}
111
112pub fn codex_compression_covered(home: &Path) -> bool {
114 file_has_compression(&codex_dir(home).join("AGENTS.md"))
115}
116
117pub fn agents_md_can_thin(home: &Path) -> bool {
125 if !cursor_compression_covered(home) {
126 return false;
127 }
128 if codex_present(home) && !codex_compression_covered(home) {
129 return false;
130 }
131 true
132}
133
134pub fn client_autoloads_compression(client_name: &str, home: &Path) -> bool {
139 let lower = client_name.to_lowercase();
140 if lower.is_empty() {
141 return false;
142 }
143 if lower.contains("cursor") {
144 return cursor_compression_covered(home);
145 }
146 if lower.contains("codex") {
147 return codex_present(home) && codex_compression_covered(home);
148 }
149 false
150}
151
152fn file_has_canonical_rules(path: &Path) -> bool {
153 std::fs::read_to_string(path)
154 .is_ok_and(|c| crate::core::rules_canonical::RulesFile::parse(&c).has_content())
155}
156
157pub fn client_autoloads_rules(client_name: &str, home: &Path) -> bool {
175 let lower = client_name.to_lowercase();
176 if lower.is_empty() {
177 return false;
178 }
179 if lower.contains("cursor") {
180 return file_has_canonical_rules(&home.join(".cursor/rules/lean-ctx.mdc"));
181 }
182 if lower.contains("codex") {
183 return codex_present(home)
184 && file_has_canonical_rules(&codex_dir(home).join("instructions.md"));
185 }
186 false
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 const FULL_HEADER: &str = crate::core::rules_canonical::START_MARK;
194
195 fn compression_block() -> String {
196 format!("{COMPRESSION_BLOCK_START}\nOUTPUT STYLE\n{COMPRESSION_BLOCK_END}\n")
197 }
198
199 fn pointer_block() -> String {
200 format!(
201 "{}\n## lean-ctx\nFull rules: ~/.cursor/rules/lean-ctx.mdc\n{}\n",
202 crate::core::rules_canonical::AGENTS_BLOCK_START,
203 crate::core::rules_canonical::AGENTS_BLOCK_END,
204 )
205 }
206
207 #[test]
208 fn full_rules_detected_for_canonical_header_and_compression() {
209 let comp = compression_block();
210 let ptr = pointer_block();
211 assert!(carries_full_rules(&format!("{FULL_HEADER}\nbody\n")));
212 assert!(carries_full_rules(&comp));
213 assert!(carries_full_rules(&format!("{ptr}{comp}")));
214 }
215
216 #[test]
217 fn pointer_only_block_is_not_full() {
218 let ptr = pointer_block();
219 assert!(!carries_full_rules(&ptr));
220 assert!(is_pointer_only(&ptr));
221 }
222
223 #[test]
224 fn plain_user_content_is_neither_full_nor_pointer() {
225 let user = "# My project rules\njust some notes\n";
226 assert!(!carries_full_rules(user));
227 assert!(!is_pointer_only(user));
228 }
229
230 #[test]
231 fn cursor_coverage_follows_mdc_block() {
232 let comp = compression_block();
233 let tmp = tempfile::tempdir().unwrap();
234 let home = tmp.path();
235 assert!(!cursor_compression_covered(home));
236
237 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
238 std::fs::write(
239 home.join(".cursor/rules/lean-ctx.mdc"),
240 format!("{FULL_HEADER}\n{comp}"),
241 )
242 .unwrap();
243 assert!(cursor_compression_covered(home));
244 }
245
246 #[test]
247 fn agents_md_thins_only_when_cursor_covered_and_no_uncovered_codex() {
248 let comp = compression_block();
249 let _guard = crate::core::data_dir::test_env_lock();
251 let tmp = tempfile::tempdir().unwrap();
252 let home = tmp.path();
253
254 crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
256 assert!(!agents_md_can_thin(home));
257
258 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
262 std::fs::write(
263 home.join(".cursor/rules/lean-ctx.mdc"),
264 format!("{FULL_HEADER}\n{comp}"),
265 )
266 .unwrap();
267 assert!(agents_md_can_thin(home));
268
269 std::fs::create_dir_all(home.join(".codex")).unwrap();
271 assert!(codex_present(home));
272 assert!(!agents_md_can_thin(home));
273
274 std::fs::write(home.join(".codex/AGENTS.md"), &comp).unwrap();
276 assert!(agents_md_can_thin(home));
277 crate::test_env::remove_var("CODEX_HOME");
278 }
279
280 #[test]
281 fn client_autoloads_rules_requires_canonical_block_on_disk() {
282 let _guard = crate::core::data_dir::test_env_lock();
283 let tmp = tempfile::tempdir().unwrap();
284 let home = tmp.path();
285 crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
286
287 assert!(!client_autoloads_rules("cursor", home));
289 assert!(!client_autoloads_rules("codex", home));
290 assert!(!client_autoloads_rules("", home));
291 assert!(!client_autoloads_rules("claude-code", home));
292
293 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
295 std::fs::write(
296 home.join(".cursor/rules/lean-ctx.mdc"),
297 format!("{FULL_HEADER}\nbody\n"),
298 )
299 .unwrap();
300 assert!(client_autoloads_rules("cursor", home));
301 assert!(client_autoloads_rules("cursor-vscode", home));
302
303 std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), "user notes\n").unwrap();
305 assert!(!client_autoloads_rules("cursor", home));
306
307 std::fs::create_dir_all(home.join(".codex")).unwrap();
309 std::fs::write(
310 home.join(".codex/instructions.md"),
311 format!("{FULL_HEADER}\nbody\n"),
312 )
313 .unwrap();
314 assert!(client_autoloads_rules("codex", home));
315 crate::test_env::remove_var("CODEX_HOME");
316 }
317
318 #[test]
319 fn cursor_hook_coverage_requires_both_pretooluse_entries() {
320 let tmp = tempfile::tempdir().unwrap();
321 let home = tmp.path();
322 assert!(!cursor_hooks_cover_native_tools(home));
324 assert!(!client_hook_covered("cursor", home));
325
326 std::fs::create_dir_all(home.join(".cursor")).unwrap();
327 let hooks = home.join(".cursor/hooks.json");
328
329 std::fs::write(
331 &hooks,
332 r#"{"version":1,"hooks":{"preToolUse":[
333 {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"}
334 ]}}"#,
335 )
336 .unwrap();
337 assert!(!cursor_hooks_cover_native_tools(home));
338
339 std::fs::write(
341 &hooks,
342 r#"{"version":1,"hooks":{"preToolUse":[
343 {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"},
344 {"matcher":"Read|Grep","command":"/usr/local/bin/lean-ctx hook redirect"}
345 ]}}"#,
346 )
347 .unwrap();
348 assert!(cursor_hooks_cover_native_tools(home));
349 assert!(client_hook_covered("cursor", home));
350 assert!(client_hook_covered("cursor-vscode", home));
351 assert!(!client_hook_covered("codex", home));
353 assert!(!client_hook_covered("", home));
354
355 std::fs::write(
357 &hooks,
358 r#"{"version":1,"hooks":{"preToolUse":[
359 {"matcher":"Shell","command":"/opt/other hook rewrite"},
360 {"matcher":"Read|Grep","command":"/opt/other hook redirect"}
361 ]}}"#,
362 )
363 .unwrap();
364 assert!(!cursor_hooks_cover_native_tools(home));
365
366 std::fs::write(&hooks, "{ not json").unwrap();
368 assert!(!cursor_hooks_cover_native_tools(home));
369 }
370
371 #[test]
372 fn client_autoloads_compression_is_client_aware() {
373 let comp = compression_block();
374 let tmp = tempfile::tempdir().unwrap();
375 let home = tmp.path();
376 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
377 std::fs::write(
378 home.join(".cursor/rules/lean-ctx.mdc"),
379 format!("{FULL_HEADER}\n{comp}"),
380 )
381 .unwrap();
382
383 assert!(client_autoloads_compression("Cursor", home));
384 assert!(client_autoloads_compression("cursor-vscode", home));
385 assert!(!client_autoloads_compression("", home));
387 assert!(!client_autoloads_compression("some-other-agent", home));
388 }
389
390 #[test]
391 fn render_output_is_detected_as_compression_coverage() {
392 use crate::core::config::CompressionLevel;
399 use crate::core::rules_canonical::{Wrapper, render};
400 use crate::core::tool_profiles::ToolProfile;
401
402 let tmp = tempfile::tempdir().unwrap();
403 let home = tmp.path();
404 assert!(!cursor_compression_covered(home));
405
406 let tp = ToolProfile::Power;
407 let block = render(false, Wrapper::Dedicated, CompressionLevel::Standard, &tp);
408 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
409 std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), &block).unwrap();
410
411 assert!(cursor_compression_covered(home));
412 assert!(client_autoloads_compression("cursor", home));
413
414 let off = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp);
415 std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), &off).unwrap();
416 assert!(!cursor_compression_covered(home));
417 }
418}