1use 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", "opencode", "codewhale", "muse"];
28
29pub fn carries_full_rules(content: &str) -> bool {
37 carries_rules_block(content) || content.contains(COMPRESSION_BLOCK_START)
38}
39
40pub const CLAUDE_BLOCK_MARKER_PREFIX: &str = "<!-- lean-ctx-claude-v";
45
46fn has_marker_line_with_prefix(content: &str, prefix: &str) -> bool {
47 content
48 .lines()
49 .any(|line| line.trim_start().starts_with(prefix))
50}
51
52pub fn carries_rules_block(content: &str) -> bool {
59 content.contains(crate::core::rules_canonical::START_MARK)
60 || has_marker_line_with_prefix(content, crate::core::rules_canonical::RULES_MARKER_PREFIX)
61 || has_marker_line_with_prefix(content, CLAUDE_BLOCK_MARKER_PREFIX)
62}
63
64pub fn claude_md_carries_block(home: &Path) -> bool {
67 std::fs::read_to_string(crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"))
68 .is_ok_and(|c| has_marker_line_with_prefix(&c, CLAUDE_BLOCK_MARKER_PREFIX))
69}
70
71pub fn is_pointer_only(content: &str) -> bool {
74 content.contains("<!-- lean-ctx") && !carries_full_rules(content)
75}
76
77fn file_has_compression(path: &Path) -> bool {
78 std::fs::read_to_string(path).is_ok_and(|c| c.contains(COMPRESSION_BLOCK_START))
79}
80
81pub fn cursor_compression_covered(home: &Path) -> bool {
84 file_has_compression(&home.join(".cursor/rules/lean-ctx.mdc"))
85}
86
87pub fn cursor_hooks_cover_native_tools(home: &Path) -> bool {
93 cursor_hooks_json_covers(&home.join(".cursor/hooks.json"))
94}
95
96pub fn cursor_hooks_json_covers(hooks_json: &Path) -> bool {
103 let Ok(content) = std::fs::read_to_string(hooks_json) else {
104 return false;
105 };
106 let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
107 return false;
108 };
109 let Some(pre) = v.pointer("/hooks/preToolUse").and_then(|p| p.as_array()) else {
110 return false;
111 };
112 let has_lean_ctx_hook = |suffix: &str| {
113 pre.iter().any(|e| {
114 e.get("command")
115 .and_then(|c| c.as_str())
116 .is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix))
117 })
118 };
119 has_lean_ctx_hook("hook rewrite") && has_lean_ctx_hook("hook redirect")
120}
121
122pub fn client_hook_covered(client_name: &str, home: &Path) -> bool {
128 let lower = client_name.to_lowercase();
129 if lower.contains("cursor") {
130 return cursor_hooks_cover_native_tools(home);
131 }
132 if lower.contains("codex") {
133 return codex_hooks_cover_native_tools(home);
134 }
135 if lower.contains("windsurf") {
136 return windsurf_hooks_cover_native_tools(home);
137 }
138 false
139}
140
141fn codex_hooks_cover_native_tools(home: &Path) -> bool {
146 let hooks_json = home.join(".codex").join("hooks.json");
147 let Ok(content) = std::fs::read_to_string(&hooks_json) else {
148 return false;
149 };
150 let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
151 return false;
152 };
153 let Some(pre) = v.pointer("/hooks/PreToolUse").and_then(|p| p.as_array()) else {
154 return false;
155 };
156 let has_lean_ctx = |suffix: &str| {
157 pre.iter().any(|e| {
158 let in_command = e
159 .get("command")
160 .and_then(|c| c.as_str())
161 .is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix));
162 let in_hooks_array = e
163 .get("hooks")
164 .and_then(|h| h.as_array())
165 .is_some_and(|hooks| {
166 hooks.iter().any(|h| {
167 h.get("command")
168 .and_then(|c| c.as_str())
169 .is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix))
170 })
171 });
172 in_command || in_hooks_array
173 })
174 };
175 has_lean_ctx("hook codex-pretooluse")
178}
179
180fn windsurf_hooks_cover_native_tools(home: &Path) -> bool {
184 let hooks_json = home.join(".codeium").join("windsurf").join("hooks.json");
185 let Ok(content) = std::fs::read_to_string(&hooks_json) else {
186 return false;
187 };
188 let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
189 return false;
190 };
191 let Some(pre) = v
192 .pointer("/hooks/pre_mcp_tool_use")
193 .and_then(|p| p.as_array())
194 else {
195 return false;
196 };
197 let has_lean_ctx = |suffix: &str| {
198 pre.iter().any(|e| {
199 e.get("command")
200 .and_then(|c| c.as_str())
201 .is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix))
202 })
203 };
204 has_lean_ctx("hook rewrite") && has_lean_ctx("hook redirect")
205}
206
207fn codex_dir(home: &Path) -> std::path::PathBuf {
209 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"))
210}
211
212pub fn codex_present(home: &Path) -> bool {
214 codex_dir(home).exists()
215}
216
217pub fn codex_compression_covered(home: &Path) -> bool {
219 file_has_compression(&codex_dir(home).join("AGENTS.md"))
220}
221
222pub fn agents_md_can_thin(home: &Path) -> bool {
230 if !cursor_compression_covered(home) {
231 return false;
232 }
233 if codex_present(home) && !codex_compression_covered(home) {
234 return false;
235 }
236 true
237}
238
239pub fn client_autoloads_compression(client_name: &str, home: &Path) -> bool {
244 let lower = client_name.to_lowercase();
245 if lower.is_empty() {
246 return false;
247 }
248 if lower.contains("cursor") {
249 return cursor_compression_covered(home);
250 }
251 if lower.contains("codex") {
252 return codex_present(home) && codex_compression_covered(home);
253 }
254 if lower.contains("opencode") {
255 let opencode_dir = home.join(".config/opencode");
256 let dedicated = opencode_dir.join("rules/lean-ctx.md");
257 let shared = opencode_dir.join("AGENTS.md");
258 return file_has_compression(&dedicated) || file_has_compression(&shared);
259 }
260 false
261}
262
263fn file_has_canonical_rules(path: &Path) -> bool {
264 std::fs::read_to_string(path)
265 .is_ok_and(|c| crate::core::rules_canonical::RulesFile::parse(&c).has_content())
266}
267
268pub fn client_autoloads_rules(client_name: &str, home: &Path) -> bool {
295 let lower = client_name.to_lowercase();
296 if lower.is_empty() {
297 return false;
298 }
299 let client_id =
300 crate::core::client_capabilities::ClientMcpCapabilities::detect(client_name).client_id;
301 if dedicated_session_hook_carries_rules(&client_id, home) {
302 return true;
303 }
304 match client_id.as_str() {
305 "claude-code" => return claude_md_carries_block(home),
306 "gemini-cli" => {
307 return file_has_canonical_rules(&home.join(".gemini/GEMINI.md"))
308 || file_has_canonical_rules(&crate::rules_inject::gemini_dedicated_rules_path(
309 home,
310 ));
311 }
312 _ => {}
313 }
314 if lower.contains("cursor") {
315 return file_has_canonical_rules(&home.join(".cursor/rules/lean-ctx.mdc"));
316 }
317 if lower.contains("codex") {
318 return codex_present(home)
319 && file_has_canonical_rules(&codex_dir(home).join("instructions.md"));
320 }
321 if lower.contains("opencode") {
322 let opencode_dir = home.join(".config/opencode");
326 let dedicated = opencode_dir.join("rules/lean-ctx.md");
327 let shared = opencode_dir.join("AGENTS.md");
328 return file_has_canonical_rules(&dedicated) || file_has_canonical_rules(&shared);
329 }
330 false
331}
332
333fn dedicated_session_hook_carries_rules(client_id: &str, home: &Path) -> bool {
337 let cfg = crate::core::config::Config::load();
338 if !cfg.dedicated_session_context_active() || cfg.declines_rule_steering() {
339 return false;
340 }
341 let settings = match client_id {
342 "claude-code" => crate::core::editor_registry::claude_state_dir(home).join("settings.json"),
343 "codex" => codex_dir(home).join("hooks.json"),
344 "codebuddy" => {
345 crate::core::editor_registry::codebuddy_state_dir(home).join("settings.json")
346 }
347 _ => return false,
348 };
349 session_start_runs_observe(&settings)
350}
351
352fn session_start_runs_observe(settings: &Path) -> bool {
355 let Ok(content) = std::fs::read_to_string(settings) else {
356 return false;
357 };
358 let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
359 return false;
360 };
361 let Some(groups) = v.pointer("/hooks/SessionStart").and_then(|g| g.as_array()) else {
362 return false;
363 };
364 groups.iter().any(|group| {
365 group
366 .get("hooks")
367 .and_then(|h| h.as_array())
368 .is_some_and(|hooks| {
369 hooks.iter().any(|hook| {
370 hook.get("command")
371 .and_then(|c| c.as_str())
372 .is_some_and(|c| c.contains("hook observe"))
373 })
374 })
375 })
376}
377
378#[cfg(test)]
379pub mod tests {
380 use super::*;
381
382 const FULL_HEADER: &str = crate::core::rules_canonical::START_MARK;
383
384 fn compression_block() -> String {
385 format!("{COMPRESSION_BLOCK_START}\nOUTPUT STYLE\n{COMPRESSION_BLOCK_END}\n")
386 }
387
388 fn pointer_block() -> String {
389 format!(
390 "{}\n## lean-ctx\nFull rules: ~/.cursor/rules/lean-ctx.mdc\n{}\n",
391 crate::core::rules_canonical::AGENTS_BLOCK_START,
392 crate::core::rules_canonical::AGENTS_BLOCK_END,
393 )
394 }
395
396 #[test]
397 fn full_rules_detected_for_canonical_header_and_compression() {
398 let comp = compression_block();
399 let ptr = pointer_block();
400 assert!(carries_full_rules(&format!("{FULL_HEADER}\nbody\n")));
401 assert!(carries_full_rules(&comp));
402 assert!(carries_full_rules(&format!("{ptr}{comp}")));
403 }
404
405 #[test]
406 fn pointer_only_block_is_not_full() {
407 let ptr = pointer_block();
408 assert!(!carries_full_rules(&ptr));
409 assert!(is_pointer_only(&ptr));
410 }
411
412 #[test]
413 fn plain_user_content_is_neither_full_nor_pointer() {
414 let user = "# My project rules\njust some notes\n";
415 assert!(!carries_full_rules(user));
416 assert!(!is_pointer_only(user));
417 }
418
419 #[test]
420 fn cursor_coverage_follows_mdc_block() {
421 let comp = compression_block();
422 let tmp = tempfile::tempdir().unwrap();
423 let home = tmp.path();
424 assert!(!cursor_compression_covered(home));
425
426 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
427 std::fs::write(
428 home.join(".cursor/rules/lean-ctx.mdc"),
429 format!("{FULL_HEADER}\n{comp}"),
430 )
431 .unwrap();
432 assert!(cursor_compression_covered(home));
433 }
434
435 #[test]
436 fn agents_md_thins_only_when_cursor_covered_and_no_uncovered_codex() {
437 let comp = compression_block();
438 let _guard = crate::core::data_dir::test_env_lock();
440 let tmp = tempfile::tempdir().unwrap();
441 let home = tmp.path();
442
443 crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
445 assert!(!agents_md_can_thin(home));
446
447 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
451 std::fs::write(
452 home.join(".cursor/rules/lean-ctx.mdc"),
453 format!("{FULL_HEADER}\n{comp}"),
454 )
455 .unwrap();
456 assert!(agents_md_can_thin(home));
457
458 std::fs::create_dir_all(home.join(".codex")).unwrap();
460 assert!(codex_present(home));
461 assert!(!agents_md_can_thin(home));
462
463 std::fs::write(home.join(".codex/AGENTS.md"), &comp).unwrap();
465 assert!(agents_md_can_thin(home));
466 crate::test_env::remove_var("CODEX_HOME");
467 }
468
469 #[test]
470 fn client_autoloads_rules_requires_canonical_block_on_disk() {
471 let _guard = crate::core::data_dir::test_env_lock();
472 let tmp = tempfile::tempdir().unwrap();
473 let home = tmp.path();
474 crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
475
476 assert!(!client_autoloads_rules("cursor", home));
478 assert!(!client_autoloads_rules("codex", home));
479 assert!(!client_autoloads_rules("", home));
480 assert!(!client_autoloads_rules("claude-code", home));
481
482 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
484 std::fs::write(
485 home.join(".cursor/rules/lean-ctx.mdc"),
486 format!("{FULL_HEADER}\nbody\n"),
487 )
488 .unwrap();
489 assert!(client_autoloads_rules("cursor", home));
490 assert!(client_autoloads_rules("cursor-vscode", home));
491
492 std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), "user notes\n").unwrap();
494 assert!(!client_autoloads_rules("cursor", home));
495
496 std::fs::create_dir_all(home.join(".codex")).unwrap();
498 std::fs::write(
499 home.join(".codex/instructions.md"),
500 format!("{FULL_HEADER}\nbody\n"),
501 )
502 .unwrap();
503 assert!(client_autoloads_rules("codex", home));
504 crate::test_env::remove_var("CODEX_HOME");
505 }
506
507 #[test]
508 fn cursor_hook_coverage_requires_both_pretooluse_entries() {
509 let tmp = tempfile::tempdir().unwrap();
510 let home = tmp.path();
511 assert!(!cursor_hooks_cover_native_tools(home));
513 assert!(!client_hook_covered("cursor", home));
514
515 std::fs::create_dir_all(home.join(".cursor")).unwrap();
516 let hooks = home.join(".cursor/hooks.json");
517
518 std::fs::write(
520 &hooks,
521 r#"{"version":1,"hooks":{"preToolUse":[
522 {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"}
523 ]}}"#,
524 )
525 .unwrap();
526 assert!(!cursor_hooks_cover_native_tools(home));
527
528 std::fs::write(
530 &hooks,
531 r#"{"version":1,"hooks":{"preToolUse":[
532 {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"},
533 {"matcher":"Read|Grep","command":"/usr/local/bin/lean-ctx hook redirect"}
534 ]}}"#,
535 )
536 .unwrap();
537 assert!(cursor_hooks_cover_native_tools(home));
538 assert!(client_hook_covered("cursor", home));
539 assert!(client_hook_covered("cursor-vscode", home));
540 assert!(!client_hook_covered("codex", home));
542 assert!(!client_hook_covered("", home));
543
544 std::fs::write(
546 &hooks,
547 r#"{"version":1,"hooks":{"preToolUse":[
548 {"matcher":"Shell","command":"/opt/other hook rewrite"},
549 {"matcher":"Read|Grep","command":"/opt/other hook redirect"}
550 ]}}"#,
551 )
552 .unwrap();
553 assert!(!cursor_hooks_cover_native_tools(home));
554
555 std::fs::write(&hooks, "{ not json").unwrap();
557 assert!(!cursor_hooks_cover_native_tools(home));
558 }
559
560 #[test]
561 fn codex_hook_coverage_requires_pretooluse() {
562 let tmp = tempfile::tempdir().unwrap();
563 let home = tmp.path();
564
565 assert!(!client_hook_covered("codex-cli", home));
566
567 let codex_dir = home.join(".codex");
568 std::fs::create_dir_all(&codex_dir).expect("create .codex dir");
569 let hooks = codex_dir.join("hooks.json");
570
571 std::fs::write(&hooks, r#"{"hooks":{"PreToolUse":[]}}"#).expect("write empty hooks");
573 assert!(!codex_hooks_cover_native_tools(home));
574
575 std::fs::write(
578 &hooks,
579 r#"{"hooks":{"PreToolUse":[
580 {"hooks":[{"command":"/usr/local/bin/lean-ctx hook codex-pretooluse","type":"command","timeout":15}],"matcher":"Bash|Read|Grep|Glob"}
581 ]}}"#,
582 )
583 .expect("write pretooluse hooks");
584 assert!(codex_hooks_cover_native_tools(home));
585 assert!(client_hook_covered("codex-cli", home));
586 assert!(client_hook_covered("codex", home));
587 assert!(!client_hook_covered("cursor", home));
589
590 std::fs::write(
592 &hooks,
593 r#"{"hooks":{"PreToolUse":[
594 {"command":"/usr/local/bin/lean-ctx hook deny","matcher":"Read|Grep|Glob"},
595 {"hooks":[{"command":"/usr/local/bin/lean-ctx hook codex-pretooluse","type":"command","timeout":15}],"matcher":"Bash"}
596 ]}}"#,
597 )
598 .expect("write legacy hooks");
599 assert!(codex_hooks_cover_native_tools(home));
600 }
601
602 #[test]
603 fn windsurf_hook_coverage_requires_rewrite_and_redirect() {
604 let tmp = tempfile::tempdir().unwrap();
605 let home = tmp.path();
606
607 assert!(!client_hook_covered("windsurf", home));
608
609 let ws_dir = home.join(".codeium").join("windsurf");
610 std::fs::create_dir_all(&ws_dir).expect("create .codeium/windsurf dir");
611 let hooks = ws_dir.join("hooks.json");
612
613 std::fs::write(
615 &hooks,
616 r#"{"hooks":{"pre_mcp_tool_use":[
617 {"command":"/usr/local/bin/lean-ctx hook rewrite"}
618 ]}}"#,
619 )
620 .expect("write rewrite-only hooks");
621 assert!(!windsurf_hooks_cover_native_tools(home));
622
623 std::fs::write(
625 &hooks,
626 r#"{"hooks":{"pre_mcp_tool_use":[
627 {"command":"/usr/local/bin/lean-ctx hook rewrite"},
628 {"command":"/usr/local/bin/lean-ctx hook redirect"}
629 ]}}"#,
630 )
631 .expect("write full hooks");
632 assert!(windsurf_hooks_cover_native_tools(home));
633 assert!(client_hook_covered("windsurf", home));
634 assert!(!client_hook_covered("cursor", home));
636 assert!(!client_hook_covered("codex", home));
637 }
638
639 #[test]
640 fn opencode_autoloads_rules_from_shared_agents_md() {
641 let tmp = tempfile::tempdir().unwrap();
642 let home = tmp.path();
643 assert!(!client_autoloads_rules("opencode", home));
644
645 std::fs::create_dir_all(home.join(".config/opencode")).unwrap();
646 std::fs::write(
647 home.join(".config/opencode/AGENTS.md"),
648 format!("{FULL_HEADER}\nbody\n"),
649 )
650 .unwrap();
651 assert!(client_autoloads_rules("opencode", home));
652 }
653
654 #[test]
655 fn opencode_autoloads_rules_from_dedicated_rules_file() {
656 let tmp = tempfile::tempdir().unwrap();
657 let home = tmp.path();
658
659 std::fs::create_dir_all(home.join(".config/opencode/rules")).unwrap();
660 std::fs::write(
661 home.join(".config/opencode/rules/lean-ctx.md"),
662 format!("{FULL_HEADER}\nbody\n"),
663 )
664 .unwrap();
665 assert!(client_autoloads_rules("opencode", home));
666 }
667
668 #[test]
669 fn client_autoloads_compression_is_client_aware() {
670 let comp = compression_block();
671 let tmp = tempfile::tempdir().unwrap();
672 let home = tmp.path();
673 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
674 std::fs::write(
675 home.join(".cursor/rules/lean-ctx.mdc"),
676 format!("{FULL_HEADER}\n{comp}"),
677 )
678 .unwrap();
679
680 assert!(client_autoloads_compression("Cursor", home));
681 assert!(client_autoloads_compression("cursor-vscode", home));
682 assert!(!client_autoloads_compression("", home));
684 assert!(!client_autoloads_compression("some-other-agent", home));
685 }
686
687 #[test]
688 fn render_output_is_detected_as_compression_coverage() {
689 use crate::core::config::CompressionLevel;
696 use crate::core::rules_canonical::{Wrapper, render};
697 use crate::core::tool_profiles::ToolProfile;
698
699 let tmp = tempfile::tempdir().unwrap();
700 let home = tmp.path();
701 assert!(!cursor_compression_covered(home));
702
703 let tp = ToolProfile::Power;
704 let block = render(false, Wrapper::Dedicated, CompressionLevel::Standard, &tp);
705 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
706 std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), &block).unwrap();
707
708 assert!(cursor_compression_covered(home));
709 assert!(client_autoloads_compression("cursor", home));
710
711 let off = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp);
712 std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), &off).unwrap();
713 assert!(!cursor_compression_covered(home));
714 }
715}