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-v7";
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-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
29// ---------------------------------------------------------------------------
30// Rules content for DEDICATED lean-ctx rule files (we control entire file).
31// LITM-optimized with critical mapping at start and end.
32// Used for: Windsurf, Zed, Cline, Roo Code, OpenCode, Continue, Aider
33// ---------------------------------------------------------------------------
34const 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
64// ---------------------------------------------------------------------------
65// Rules for Cursor MDC format (dedicated file with frontmatter).
66// alwaysApply: true ensures this is loaded into EVERY conversation.
67// LITM-optimized: critical warning at top and bottom.
68// ---------------------------------------------------------------------------
69const 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
105// ---------------------------------------------------------------------------
106
107struct 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
152// ---------------------------------------------------------------------------
153// Injection logic
154// ---------------------------------------------------------------------------
155
156enum 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
270// ---------------------------------------------------------------------------
271// Tool detection
272// ---------------------------------------------------------------------------
273
274fn 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
393// ---------------------------------------------------------------------------
394// Target definitions
395// ---------------------------------------------------------------------------
396
397fn build_rules_targets(home: &std::path::Path) -> Vec<RulesTarget> {
398    vec![
399        // --- Shared config files (append-only) ---
400        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        // --- Dedicated lean-ctx rule files ---
421        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// ---------------------------------------------------------------------------
534// Tests
535// ---------------------------------------------------------------------------
536
537#[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}