1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5const MARKER: &str = "# lean-ctx — Context Engineering Layer";
6const END_MARKER: &str = "<!-- /lean-ctx -->";
7const RULES_VERSION: &str = "lean-ctx-rules-v9";
8
9pub const RULES_MARKER: &str = MARKER;
10pub const RULES_VERSION_STR: &str = RULES_VERSION;
11
12pub fn rules_dedicated_markdown() -> &'static str {
13 RULES_DEDICATED
14}
15
16const RULES_SHARED: &str = r#"# lean-ctx — Context Engineering Layer
22<!-- lean-ctx-rules-v9 -->
23
24CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents. This is NOT optional.
25
26| ALWAYS USE | NEVER USE | Why |
27|------------|-----------|-----|
28| `ctx_read(path, mode)` | `Read` / `cat` / `head` / `tail` | Cached, 10 read modes, re-reads ~13 tokens |
29| `ctx_shell(command)` | `Shell` / `bash` / terminal | Pattern compression for git/npm/cargo output |
30| `ctx_search(pattern, path)` | `Grep` / `rg` | Compact, token-efficient results |
31| `ctx_tree(path, depth)` | `ls` / `find` | Compact directory maps |
32
33Compatibility: ctx_read replaces READ operations only. Your native Edit/Write/StrReplace tools remain unchanged — keep using them for editing. If your rules say "use Edit or Write tools only", that is compatible: lean-ctx only replaces how you READ files, not how you EDIT them.
34
35If Edit requires native Read and Read is unavailable, use `ctx_edit(path, old_string, new_string)` instead.
36Write, Delete, Glob → use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
37
38Preferred workflow control: use `ctx_workflow` to track states + enforce tool gates + evidence.
39
40Fallback only if a lean-ctx tool is unavailable: use native equivalents.
41<!-- /lean-ctx -->"#;
42
43const RULES_DEDICATED: &str = r#"# lean-ctx — Context Engineering Layer
49<!-- lean-ctx-rules-v9 -->
50
51PREFER lean-ctx MCP tools over native equivalents for token savings:
52
53## Tool preference:
54| PREFER | OVER | Why |
55|--------|------|-----|
56| `ctx_read(path, mode)` | `Read` / `cat` | Cached, 10 read modes, re-reads ~13 tokens |
57| `ctx_shell(command)` | `Shell` / `bash` | Pattern compression for git/npm/cargo output |
58| `ctx_search(pattern, path)` | `Grep` / `rg` | Compact, token-efficient results |
59| `ctx_tree(path, depth)` | `ls` / `find` | Compact directory maps |
60| `ctx_edit(path, old_string, new_string)` | `Edit` (when Read unavailable) | Search-and-replace without native Read |
61
62## ctx_read modes:
63- `auto` — auto-select optimal mode (recommended default)
64- `full` — cached read (files you edit)
65- `map` — deps + exports (context-only files)
66- `signatures` — API surface only
67- `diff` — changed lines after edits
68- `aggressive` — maximum compression (context only)
69- `entropy` — highlight high-entropy fragments
70- `task` — IB-filtered (task relevant)
71- `reference` — quote-friendly minimal excerpts
72- `lines:N-M` — specific range
73
74## File editing:
75Use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
76Write, Delete, Glob → use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
77
78## Proactive (use without being asked):
79- `ctx_overview(task)` at session start
80- `ctx_compress` when context grows large
81<!-- /lean-ctx -->"#;
82
83const RULES_CURSOR_MDC: &str = r#"---
89description: "lean-ctx: prefer ctx_read/ctx_shell/ctx_search/ctx_tree over Read/Shell/Grep/ls for token savings"
90alwaysApply: true
91---
92
93# lean-ctx — Context Engineering Layer
94<!-- lean-ctx-rules-v9 -->
95
96PREFER lean-ctx MCP tools over native equivalents for token savings:
97
98## Tool Mapping
99
100| PREFER | OVER | Why |
101|--------|------|-----|
102| `ctx_read` | `Read` | Session caching, 10 read modes, re-reads cost ~13 tokens |
103| `ctx_shell` | `Shell` | Pattern-based compression for git, npm, cargo, docker, tsc |
104| `ctx_search` | `Grep` | Compact context, token-efficient results |
105| `ctx_tree` | `ls`, `find` | Compact directory maps with file counts |
106| `ctx_edit` | `Edit` (when Read unavailable) | Search-and-replace without native Read dependency |
107
108## ctx_read Modes
109
110- `auto` — auto-select optimal mode (recommended default)
111- `full` — cached read (use for files you will edit)
112- `map` — dependency graph + exports + key signatures (use for context-only files)
113- `signatures` — API surface only
114- `diff` — changed lines only (use after edits)
115- `aggressive` — maximum compression (context only)
116- `entropy` — highlight high-entropy fragments
117- `task` — IB-filtered (task relevant)
118- `reference` — quote-friendly minimal excerpts
119- `lines:N-M` — specific range
120
121## File editing
122
123- Use native Edit/StrReplace when available.
124- If Edit requires native Read and Read is unavailable: use `ctx_edit(path, old_string, new_string)` instead.
125- NEVER loop trying to make Edit work. If it fails, switch to ctx_edit immediately.
126- Write, Delete, Glob → use normally.
127- Fallback only if a lean-ctx tool is unavailable: use native equivalents.
128<!-- /lean-ctx -->"#;
129
130struct RulesTarget {
133 name: &'static str,
134 path: PathBuf,
135 format: RulesFormat,
136}
137
138enum RulesFormat {
139 SharedMarkdown,
140 DedicatedMarkdown,
141 CursorMdc,
142}
143
144pub struct InjectResult {
145 pub injected: Vec<String>,
146 pub updated: Vec<String>,
147 pub already: Vec<String>,
148 pub errors: Vec<String>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct RulesTargetStatus {
153 pub name: String,
154 pub detected: bool,
155 pub path: String,
156 pub state: String,
157 pub note: Option<String>,
158}
159
160pub fn inject_all_rules(home: &std::path::Path) -> InjectResult {
161 if crate::core::config::Config::load().rules_scope_effective()
162 == crate::core::config::RulesScope::Project
163 {
164 return InjectResult {
165 injected: Vec::new(),
166 updated: Vec::new(),
167 already: Vec::new(),
168 errors: Vec::new(),
169 };
170 }
171
172 let targets = build_rules_targets(home);
173
174 let mut result = InjectResult {
175 injected: Vec::new(),
176 updated: Vec::new(),
177 already: Vec::new(),
178 errors: Vec::new(),
179 };
180
181 for target in &targets {
182 if !is_tool_detected(target, home) {
183 continue;
184 }
185
186 match inject_rules(target) {
187 Ok(RulesResult::Injected) => result.injected.push(target.name.to_string()),
188 Ok(RulesResult::Updated) => result.updated.push(target.name.to_string()),
189 Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
190 Err(e) => result.errors.push(format!("{}: {e}", target.name)),
191 }
192 }
193
194 result
195}
196
197pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
198 let targets = build_rules_targets(home);
199 let mut out = Vec::new();
200
201 for target in &targets {
202 let detected = is_tool_detected(target, home);
203 let path = target.path.to_string_lossy().to_string();
204
205 let state = if !detected {
206 "not_detected".to_string()
207 } else if !target.path.exists() {
208 "missing".to_string()
209 } else {
210 match std::fs::read_to_string(&target.path) {
211 Ok(content) => {
212 if content.contains(MARKER) {
213 if content.contains(RULES_VERSION) {
214 "up_to_date".to_string()
215 } else {
216 "outdated".to_string()
217 }
218 } else {
219 "present_without_marker".to_string()
220 }
221 }
222 Err(_) => "read_error".to_string(),
223 }
224 };
225
226 out.push(RulesTargetStatus {
227 name: target.name.to_string(),
228 detected,
229 path,
230 state,
231 note: None,
232 });
233 }
234
235 out
236}
237
238enum RulesResult {
243 Injected,
244 Updated,
245 AlreadyPresent,
246}
247
248fn rules_content(format: &RulesFormat) -> &'static str {
249 match format {
250 RulesFormat::SharedMarkdown => RULES_SHARED,
251 RulesFormat::DedicatedMarkdown => RULES_DEDICATED,
252 RulesFormat::CursorMdc => RULES_CURSOR_MDC,
253 }
254}
255
256fn inject_rules(target: &RulesTarget) -> Result<RulesResult, String> {
257 if target.path.exists() {
258 let content = std::fs::read_to_string(&target.path).map_err(|e| e.to_string())?;
259 if content.contains(MARKER) {
260 if content.contains(RULES_VERSION) {
261 return Ok(RulesResult::AlreadyPresent);
262 }
263 ensure_parent(&target.path)?;
264 return match target.format {
265 RulesFormat::SharedMarkdown => replace_markdown_section(&target.path, &content),
266 RulesFormat::DedicatedMarkdown | RulesFormat::CursorMdc => {
267 write_dedicated(&target.path, rules_content(&target.format))
268 }
269 };
270 }
271 }
272
273 ensure_parent(&target.path)?;
274
275 match target.format {
276 RulesFormat::SharedMarkdown => append_to_shared(&target.path),
277 RulesFormat::DedicatedMarkdown | RulesFormat::CursorMdc => {
278 write_dedicated(&target.path, rules_content(&target.format))
279 }
280 }
281}
282
283fn ensure_parent(path: &std::path::Path) -> Result<(), String> {
284 if let Some(parent) = path.parent() {
285 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
286 }
287 Ok(())
288}
289
290fn append_to_shared(path: &std::path::Path) -> Result<RulesResult, String> {
291 let mut content = if path.exists() {
292 std::fs::read_to_string(path).map_err(|e| e.to_string())?
293 } else {
294 String::new()
295 };
296
297 if !content.is_empty() && !content.ends_with('\n') {
298 content.push('\n');
299 }
300 if !content.is_empty() {
301 content.push('\n');
302 }
303 content.push_str(RULES_SHARED);
304 content.push('\n');
305
306 std::fs::write(path, content).map_err(|e| e.to_string())?;
307 Ok(RulesResult::Injected)
308}
309
310fn replace_markdown_section(path: &std::path::Path, content: &str) -> Result<RulesResult, String> {
311 let start = content.find(MARKER);
312 let end = content.find(END_MARKER);
313
314 let new_content = match (start, end) {
315 (Some(s), Some(e)) => {
316 let before = &content[..s];
317 let after_end = e + END_MARKER.len();
318 let after = content[after_end..].trim_start_matches('\n');
319 let mut result = before.to_string();
320 result.push_str(RULES_SHARED);
321 if !after.is_empty() {
322 result.push('\n');
323 result.push_str(after);
324 }
325 result
326 }
327 (Some(s), None) => {
328 let before = &content[..s];
329 let mut result = before.to_string();
330 result.push_str(RULES_SHARED);
331 result.push('\n');
332 result
333 }
334 _ => return Ok(RulesResult::AlreadyPresent),
335 };
336
337 std::fs::write(path, new_content).map_err(|e| e.to_string())?;
338 Ok(RulesResult::Updated)
339}
340
341fn write_dedicated(path: &std::path::Path, content: &'static str) -> Result<RulesResult, String> {
342 let is_update = path.exists() && {
343 let existing = std::fs::read_to_string(path).unwrap_or_default();
344 existing.contains(MARKER)
345 };
346
347 std::fs::write(path, content).map_err(|e| e.to_string())?;
348
349 if is_update {
350 Ok(RulesResult::Updated)
351 } else {
352 Ok(RulesResult::Injected)
353 }
354}
355
356fn is_tool_detected(target: &RulesTarget, home: &std::path::Path) -> bool {
361 match target.name {
362 "Claude Code" => {
363 if command_exists("claude") {
364 return true;
365 }
366 let state_dir = crate::core::editor_registry::claude_state_dir(home);
367 crate::core::editor_registry::claude_mcp_json_path(home).exists() || state_dir.exists()
368 }
369 "Codex CLI" => home.join(".codex").exists() || command_exists("codex"),
370 "Cursor" => home.join(".cursor").exists(),
371 "Windsurf" => home.join(".codeium/windsurf").exists(),
372 "Gemini CLI" => home.join(".gemini").exists(),
373 "VS Code / Copilot" => detect_vscode_installed(home),
374 "Zed" => home.join(".config/zed").exists(),
375 "Cline" => detect_extension_installed(home, "saoudrizwan.claude-dev"),
376 "Roo Code" => detect_extension_installed(home, "rooveterinaryinc.roo-cline"),
377 "OpenCode" => home.join(".config/opencode").exists(),
378 "Continue" => detect_extension_installed(home, "continue.continue"),
379 "Aider" => command_exists("aider") || home.join(".aider.conf.yml").exists(),
380 "Amp" => command_exists("amp") || home.join(".ampcoder").exists(),
381 "Qwen Code" => home.join(".qwen").exists(),
382 "Trae" => home.join(".trae").exists(),
383 "Amazon Q Developer" => home.join(".aws/amazonq").exists(),
384 "JetBrains IDEs" => detect_jetbrains_installed(home),
385 "Antigravity" => home.join(".gemini/antigravity").exists(),
386 "Pi Coding Agent" => home.join(".pi").exists() || command_exists("pi"),
387 "AWS Kiro" => home.join(".kiro").exists(),
388 "Crush" => home.join(".config/crush").exists() || command_exists("crush"),
389 "Verdent" => home.join(".verdent").exists(),
390 _ => false,
391 }
392}
393
394fn command_exists(name: &str) -> bool {
395 #[cfg(target_os = "windows")]
396 let result = std::process::Command::new("where")
397 .arg(name)
398 .output()
399 .map(|o| o.status.success())
400 .unwrap_or(false);
401
402 #[cfg(not(target_os = "windows"))]
403 let result = std::process::Command::new("which")
404 .arg(name)
405 .output()
406 .map(|o| o.status.success())
407 .unwrap_or(false);
408
409 result
410}
411
412fn detect_vscode_installed(_home: &std::path::Path) -> bool {
413 let check_dir = |dir: PathBuf| -> bool {
414 dir.join("settings.json").exists() || dir.join("mcp.json").exists()
415 };
416
417 #[cfg(target_os = "macos")]
418 if check_dir(_home.join("Library/Application Support/Code/User")) {
419 return true;
420 }
421 #[cfg(target_os = "linux")]
422 if check_dir(_home.join(".config/Code/User")) {
423 return true;
424 }
425 #[cfg(target_os = "windows")]
426 if let Ok(appdata) = std::env::var("APPDATA") {
427 if check_dir(PathBuf::from(&appdata).join("Code/User")) {
428 return true;
429 }
430 }
431 false
432}
433
434fn detect_jetbrains_installed(home: &std::path::Path) -> bool {
435 #[cfg(target_os = "macos")]
436 if home.join("Library/Application Support/JetBrains").exists() {
437 return true;
438 }
439 #[cfg(target_os = "linux")]
440 if home.join(".config/JetBrains").exists() {
441 return true;
442 }
443 home.join(".jb-mcp.json").exists()
444}
445
446fn detect_extension_installed(_home: &std::path::Path, extension_id: &str) -> bool {
447 #[cfg(target_os = "macos")]
448 {
449 if _home
450 .join(format!(
451 "Library/Application Support/Code/User/globalStorage/{extension_id}"
452 ))
453 .exists()
454 {
455 return true;
456 }
457 }
458 #[cfg(target_os = "linux")]
459 {
460 if _home
461 .join(format!(".config/Code/User/globalStorage/{extension_id}"))
462 .exists()
463 {
464 return true;
465 }
466 }
467 #[cfg(target_os = "windows")]
468 {
469 if let Ok(appdata) = std::env::var("APPDATA") {
470 if std::path::PathBuf::from(&appdata)
471 .join(format!("Code/User/globalStorage/{extension_id}"))
472 .exists()
473 {
474 return true;
475 }
476 }
477 }
478 false
479}
480
481fn build_rules_targets(home: &std::path::Path) -> Vec<RulesTarget> {
486 vec![
487 RulesTarget {
489 name: "Claude Code",
490 path: crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
491 format: RulesFormat::DedicatedMarkdown,
492 },
493 RulesTarget {
494 name: "Codex CLI",
495 path: home.join(".codex/instructions.md"),
496 format: RulesFormat::SharedMarkdown,
497 },
498 RulesTarget {
499 name: "Gemini CLI",
500 path: home.join(".gemini/GEMINI.md"),
501 format: RulesFormat::SharedMarkdown,
502 },
503 RulesTarget {
504 name: "VS Code / Copilot",
505 path: copilot_instructions_path(home),
506 format: RulesFormat::SharedMarkdown,
507 },
508 RulesTarget {
510 name: "Cursor",
511 path: home.join(".cursor/rules/lean-ctx.mdc"),
512 format: RulesFormat::CursorMdc,
513 },
514 RulesTarget {
515 name: "Windsurf",
516 path: home.join(".codeium/windsurf/rules/lean-ctx.md"),
517 format: RulesFormat::DedicatedMarkdown,
518 },
519 RulesTarget {
520 name: "Zed",
521 path: home.join(".config/zed/rules/lean-ctx.md"),
522 format: RulesFormat::DedicatedMarkdown,
523 },
524 RulesTarget {
525 name: "Cline",
526 path: home.join(".cline/rules/lean-ctx.md"),
527 format: RulesFormat::DedicatedMarkdown,
528 },
529 RulesTarget {
530 name: "Roo Code",
531 path: home.join(".roo/rules/lean-ctx.md"),
532 format: RulesFormat::DedicatedMarkdown,
533 },
534 RulesTarget {
535 name: "OpenCode",
536 path: home.join(".config/opencode/rules/lean-ctx.md"),
537 format: RulesFormat::DedicatedMarkdown,
538 },
539 RulesTarget {
540 name: "Continue",
541 path: home.join(".continue/rules/lean-ctx.md"),
542 format: RulesFormat::DedicatedMarkdown,
543 },
544 RulesTarget {
545 name: "Aider",
546 path: home.join(".aider/rules/lean-ctx.md"),
547 format: RulesFormat::DedicatedMarkdown,
548 },
549 RulesTarget {
550 name: "Amp",
551 path: home.join(".ampcoder/rules/lean-ctx.md"),
552 format: RulesFormat::DedicatedMarkdown,
553 },
554 RulesTarget {
555 name: "Qwen Code",
556 path: home.join(".qwen/rules/lean-ctx.md"),
557 format: RulesFormat::DedicatedMarkdown,
558 },
559 RulesTarget {
560 name: "Trae",
561 path: home.join(".trae/rules/lean-ctx.md"),
562 format: RulesFormat::DedicatedMarkdown,
563 },
564 RulesTarget {
565 name: "Amazon Q Developer",
566 path: home.join(".aws/amazonq/rules/lean-ctx.md"),
567 format: RulesFormat::DedicatedMarkdown,
568 },
569 RulesTarget {
570 name: "JetBrains IDEs",
571 path: home.join(".jb-rules/lean-ctx.md"),
572 format: RulesFormat::DedicatedMarkdown,
573 },
574 RulesTarget {
575 name: "Antigravity",
576 path: home.join(".gemini/antigravity/rules/lean-ctx.md"),
577 format: RulesFormat::DedicatedMarkdown,
578 },
579 RulesTarget {
580 name: "Pi Coding Agent",
581 path: home.join(".pi/rules/lean-ctx.md"),
582 format: RulesFormat::DedicatedMarkdown,
583 },
584 RulesTarget {
585 name: "AWS Kiro",
586 path: home.join(".kiro/steering/lean-ctx.md"),
587 format: RulesFormat::DedicatedMarkdown,
588 },
589 RulesTarget {
590 name: "Verdent",
591 path: home.join(".verdent/rules/lean-ctx.md"),
592 format: RulesFormat::DedicatedMarkdown,
593 },
594 RulesTarget {
595 name: "Crush",
596 path: home.join(".config/crush/rules/lean-ctx.md"),
597 format: RulesFormat::DedicatedMarkdown,
598 },
599 ]
600}
601
602fn copilot_instructions_path(home: &std::path::Path) -> PathBuf {
603 #[cfg(target_os = "macos")]
604 {
605 return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
606 }
607 #[cfg(target_os = "linux")]
608 {
609 return home.join(".config/Code/User/github-copilot-instructions.md");
610 }
611 #[cfg(target_os = "windows")]
612 {
613 if let Ok(appdata) = std::env::var("APPDATA") {
614 return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
615 }
616 }
617 #[allow(unreachable_code)]
618 home.join(".config/Code/User/github-copilot-instructions.md")
619}
620
621#[cfg(test)]
626mod tests {
627 use super::*;
628
629 #[test]
630 fn shared_rules_have_markers() {
631 assert!(RULES_SHARED.contains(MARKER));
632 assert!(RULES_SHARED.contains(END_MARKER));
633 assert!(RULES_SHARED.contains(RULES_VERSION));
634 }
635
636 #[test]
637 fn dedicated_rules_have_markers() {
638 assert!(RULES_DEDICATED.contains(MARKER));
639 assert!(RULES_DEDICATED.contains(END_MARKER));
640 assert!(RULES_DEDICATED.contains(RULES_VERSION));
641 }
642
643 #[test]
644 fn cursor_mdc_has_markers_and_frontmatter() {
645 assert!(RULES_CURSOR_MDC.contains("lean-ctx"));
646 assert!(RULES_CURSOR_MDC.contains(END_MARKER));
647 assert!(RULES_CURSOR_MDC.contains(RULES_VERSION));
648 assert!(RULES_CURSOR_MDC.contains("alwaysApply: true"));
649 }
650
651 #[test]
652 fn shared_rules_contain_tool_mapping() {
653 assert!(RULES_SHARED.contains("ctx_read"));
654 assert!(RULES_SHARED.contains("ctx_shell"));
655 assert!(RULES_SHARED.contains("ctx_search"));
656 assert!(RULES_SHARED.contains("ctx_tree"));
657 assert!(RULES_SHARED.contains("Write"));
658 }
659
660 #[test]
661 fn shared_rules_litm_optimized() {
662 let lines: Vec<&str> = RULES_SHARED.lines().collect();
663 let first_5 = lines[..5.min(lines.len())].join("\n");
664 assert!(
665 first_5.contains("PREFER") || first_5.contains("lean-ctx"),
666 "LITM: preference instruction must be near start"
667 );
668 let last_5 = lines[lines.len().saturating_sub(5)..].join("\n");
669 assert!(
670 last_5.contains("fallback") || last_5.contains("native"),
671 "LITM: fallback note must be near end"
672 );
673 }
674
675 #[test]
676 fn dedicated_rules_contain_modes() {
677 assert!(RULES_DEDICATED.contains("auto"));
678 assert!(RULES_DEDICATED.contains("full"));
679 assert!(RULES_DEDICATED.contains("map"));
680 assert!(RULES_DEDICATED.contains("signatures"));
681 assert!(RULES_DEDICATED.contains("diff"));
682 assert!(RULES_DEDICATED.contains("aggressive"));
683 assert!(RULES_DEDICATED.contains("entropy"));
684 assert!(RULES_DEDICATED.contains("task"));
685 assert!(RULES_DEDICATED.contains("reference"));
686 assert!(RULES_DEDICATED.contains("ctx_read"));
687 }
688
689 #[test]
690 fn dedicated_rules_litm_optimized() {
691 let lines: Vec<&str> = RULES_DEDICATED.lines().collect();
692 let first_5 = lines[..5.min(lines.len())].join("\n");
693 assert!(
694 first_5.contains("PREFER") || first_5.contains("lean-ctx"),
695 "LITM: preference instruction must be near start"
696 );
697 let last_5 = lines[lines.len().saturating_sub(5)..].join("\n");
698 assert!(
699 last_5.contains("fallback") || last_5.contains("ctx_compress"),
700 "LITM: practical note must be near end"
701 );
702 }
703
704 #[test]
705 fn cursor_mdc_litm_optimized() {
706 let lines: Vec<&str> = RULES_CURSOR_MDC.lines().collect();
707 let first_10 = lines[..10.min(lines.len())].join("\n");
708 assert!(
709 first_10.contains("PREFER") || first_10.contains("lean-ctx"),
710 "LITM: preference instruction must be near start of MDC"
711 );
712 let last_5 = lines[lines.len().saturating_sub(5)..].join("\n");
713 assert!(
714 last_5.contains("fallback") || last_5.contains("native"),
715 "LITM: fallback note must be near end of MDC"
716 );
717 }
718
719 fn ensure_temp_dir() {
720 let tmp = std::env::temp_dir();
721 if !tmp.exists() {
722 std::fs::create_dir_all(&tmp).ok();
723 }
724 }
725
726 #[test]
727 fn replace_section_with_end_marker() {
728 ensure_temp_dir();
729 let old = "user stuff\n\n# lean-ctx — Context Engineering Layer\n<!-- lean-ctx-rules-v2 -->\nold rules\n<!-- /lean-ctx -->\nmore user stuff\n";
730 let path = std::env::temp_dir().join("test_replace_with_end.md");
731 std::fs::write(&path, old).unwrap();
732
733 let result = replace_markdown_section(&path, old).unwrap();
734 assert!(matches!(result, RulesResult::Updated));
735
736 let new_content = std::fs::read_to_string(&path).unwrap();
737 assert!(new_content.contains(RULES_VERSION));
738 assert!(new_content.starts_with("user stuff"));
739 assert!(new_content.contains("more user stuff"));
740 assert!(!new_content.contains("lean-ctx-rules-v2"));
741
742 std::fs::remove_file(&path).ok();
743 }
744
745 #[test]
746 fn replace_section_without_end_marker() {
747 ensure_temp_dir();
748 let old = "user stuff\n\n# lean-ctx — Context Engineering Layer\nold rules only\n";
749 let path = std::env::temp_dir().join("test_replace_no_end.md");
750 std::fs::write(&path, old).unwrap();
751
752 let result = replace_markdown_section(&path, old).unwrap();
753 assert!(matches!(result, RulesResult::Updated));
754
755 let new_content = std::fs::read_to_string(&path).unwrap();
756 assert!(new_content.contains(RULES_VERSION));
757 assert!(new_content.starts_with("user stuff"));
758
759 std::fs::remove_file(&path).ok();
760 }
761
762 #[test]
763 fn append_to_shared_preserves_existing() {
764 ensure_temp_dir();
765 let path = std::env::temp_dir().join("test_append_shared.md");
766 std::fs::write(&path, "existing user rules\n").unwrap();
767
768 let result = append_to_shared(&path).unwrap();
769 assert!(matches!(result, RulesResult::Injected));
770
771 let content = std::fs::read_to_string(&path).unwrap();
772 assert!(content.starts_with("existing user rules"));
773 assert!(content.contains(MARKER));
774 assert!(content.contains(END_MARKER));
775
776 std::fs::remove_file(&path).ok();
777 }
778
779 #[test]
780 fn write_dedicated_creates_file() {
781 ensure_temp_dir();
782 let path = std::env::temp_dir().join("test_write_dedicated.md");
783 if path.exists() {
784 std::fs::remove_file(&path).ok();
785 }
786
787 let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
788 assert!(matches!(result, RulesResult::Injected));
789
790 let content = std::fs::read_to_string(&path).unwrap();
791 assert!(content.contains(MARKER));
792 assert!(content.contains("ctx_read modes"));
793
794 std::fs::remove_file(&path).ok();
795 }
796
797 #[test]
798 fn write_dedicated_updates_existing() {
799 ensure_temp_dir();
800 let path = std::env::temp_dir().join("test_write_dedicated_update.md");
801 std::fs::write(&path, "# lean-ctx — Context Engineering Layer\nold version").unwrap();
802
803 let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
804 assert!(matches!(result, RulesResult::Updated));
805
806 std::fs::remove_file(&path).ok();
807 }
808
809 #[test]
810 fn target_count() {
811 let home = std::path::PathBuf::from("/tmp/fake_home");
812 let targets = build_rules_targets(&home);
813 assert_eq!(targets.len(), 22);
814 }
815}