Skip to main content

lean_ctx/
rules_inject.rs

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