Skip to main content

lean_ctx/
rules_inject.rs

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