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