Skip to main content

lean_ctx/
rules_inject.rs

1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5const MARKER: &str = "# lean-ctx — Context Engineering Layer";
6const END_MARKER: &str = "<!-- /lean-ctx -->";
7const RULES_VERSION: &str = "lean-ctx-rules-v11";
8
9pub const RULES_MARKER: &str = MARKER;
10pub const RULES_END_MARKER: &str = END_MARKER;
11pub const RULES_VERSION_STR: &str = RULES_VERSION;
12
13pub fn rules_dedicated_markdown() -> &'static str {
14    RULES_DEDICATED
15}
16
17pub fn rules_shared_content() -> &'static str {
18    RULES_SHARED
19}
20
21/// Compact, agent-agnostic tool-mapping summary injected as `SessionStart`
22/// `additionalContext` in `rules_injection = "dedicated"` mode.
23///
24/// It is the always-loaded counterpart of the (now-skipped) CLAUDE.md/AGENTS.md
25/// marker block, so it carries the same essential per-session footprint. The
26/// full ruleset stays lazy in the removable dedicated rules file. No HTML markers
27/// and no `@import` pointer (Codex has no `@import`), so it renders cleanly as a
28/// developer/system reminder on both Claude and Codex.
29pub fn dedicated_session_summary() -> &'static str {
30    DEDICATED_SESSION_SUMMARY
31}
32
33const DEDICATED_SESSION_SUMMARY: &str =
34    "lean-ctx is active — prefer its tools over native equivalents to save context tokens:
35- ctx_read  → instead of Read/cat/head/tail (cached, 10 modes, re-reads ~13 tokens)
36- ctx_shell → instead of bash/Shell (95+ output-compression patterns)
37- ctx_search → instead of Grep/rg/find (compact, token-efficient results)
38- ctx_tree  → instead of ls/find (compact directory maps)
39Native Edit/Write/Glob stay as-is. If Edit needs Read and Read is unavailable, use ctx_edit.
40NEVER use native Read/Grep/Shell when a ctx_* equivalent exists.";
41
42/// Dedicated-mode rules file for OpenCode, auto-loaded via `opencode.json`
43/// `instructions[]` (absolute path) instead of polluting `~/.config/opencode/AGENTS.md`.
44pub fn opencode_dedicated_rules_path(home: &std::path::Path) -> PathBuf {
45    home.join(".config/opencode/rules/lean-ctx.md")
46}
47
48/// Dedicated-mode rules file for Gemini CLI, auto-loaded via `settings.json`
49/// `context.fileName` instead of polluting `~/.gemini/GEMINI.md`. The filename is
50/// deliberately distinctive: Gemini discovers context files *by name* across the
51/// project tree, so a generic `lean-ctx.md` would get picked up everywhere.
52pub fn gemini_dedicated_rules_path(home: &std::path::Path) -> PathBuf {
53    home.join(".gemini").join(GEMINI_DEDICATED_CONTEXT_FILENAME)
54}
55
56/// The `context.fileName` entry registered for Gemini in dedicated mode.
57pub const GEMINI_DEDICATED_CONTEXT_FILENAME: &str = "LEANCTX.md";
58
59// ---------------------------------------------------------------------------
60// Rules content for SHARED config files (appended to user's existing config).
61// LITM-optimized: critical instruction at START and END of block.
62// Used for: CLAUDE.md, instructions.md, GEMINI.md, copilot-instructions.md
63// ---------------------------------------------------------------------------
64const RULES_SHARED: &str = r#"# lean-ctx — Context Engineering Layer
65<!-- lean-ctx-rules-v11 -->
66
67## Tool Mapping (MANDATORY — use instead of native equivalents)
68| Instead of | Use | Example |
69|------------|-----|---------|
70| Read/cat/head/tail | `ctx_read(path, mode)` | `ctx_read("src/main.rs", "full")` |
71| Grep/rg/find | `ctx_search(pattern, path)` | `ctx_search("fn handle", "src/")` |
72| Shell/bash | `ctx_shell(command)` | `ctx_shell("cargo test")` |
73| Edit (when Read unavailable) | `ctx_edit(path, old, new)` | `ctx_edit("f.rs", "old", "new")` |
74
75## ctx_read Mode Selection
76| Goal | Mode | When |
77|------|------|------|
78| Edit this file | `full` | Before any edit |
79| Understand API | `signatures` | Context-only, won't edit |
80| Re-read after edit | `diff` | Post-edit verification |
81| Large file overview | `map` | >500 lines, won't edit |
82| Specific region | `lines:N-M` | Know exact location |
83
84## Workflow (follow this order)
851. **Orient:** `ctx_overview(task)` or `ctx_compose(task, path)` for unfamiliar tasks
862. **Locate:** `ctx_search(pattern, path)` for exact text; `ctx_semantic_search(query)` for concepts
873. **Read:** `ctx_read(path, mode)` with appropriate mode from table above
884. **Edit:** `ctx_edit(path, old_string, new_string)` or native Edit if available
895. **Verify:** `ctx_read(path, "diff")` + `ctx_shell("test command")`
906. **Record:** `ctx_knowledge(action="remember", content="...")` for non-obvious findings
91
92## Session
93- **Start:** `ctx_session(action="status")` + `ctx_knowledge(action="wakeup")`
94- **End:** `ctx_session(action="decision", content="what was done + next steps")`
95- **On [CHECKPOINT]:** `ctx_session(action="task", value="current status")`
96
97NEVER use native Read/Grep/Shell when ctx_* equivalents are available.
98<!-- /lean-ctx -->"#;
99
100// ---------------------------------------------------------------------------
101// Rules content for DEDICATED lean-ctx rule files (we control entire file).
102// LITM-optimized with critical mapping at start and end.
103// Used for: Windsurf, Zed, Cline, Roo Code, OpenCode, Continue, Aider
104// ---------------------------------------------------------------------------
105const RULES_DEDICATED: &str = r#"# lean-ctx — Context Engineering Layer
106<!-- lean-ctx-rules-v11 -->
107
108## Tool Mapping (MANDATORY — use instead of native equivalents)
109| Instead of | Use | Example |
110|------------|-----|---------|
111| Read/cat/head/tail | `ctx_read(path, mode)` | `ctx_read("src/main.rs", "full")` |
112| Grep/rg/find | `ctx_search(pattern, path)` | `ctx_search("fn handle", "src/")` |
113| Shell/bash | `ctx_shell(command)` | `ctx_shell("cargo test")` |
114| Edit (when Read unavailable) | `ctx_edit(path, old, new)` | `ctx_edit("f.rs", "old", "new")` |
115
116## ctx_read Mode Selection
117| Goal | Mode | When |
118|------|------|------|
119| Edit this file | `full` | Before any edit |
120| Understand API | `signatures` | Context-only, won't edit |
121| Re-read after edit | `diff` | Post-edit verification |
122| Large file overview | `map` | >500 lines, won't edit |
123| Specific region | `lines:N-M` | Know exact location |
124| Unsure | `auto` | System selects optimal mode |
125
126## Workflow (follow this order)
1271. **Orient:** `ctx_overview(task)` or `ctx_compose(task, path)` for unfamiliar tasks
1282. **Locate:** `ctx_search(pattern, path)` for exact text; `ctx_semantic_search(query)` for concepts
1293. **Read:** `ctx_read(path, mode)` with appropriate mode from table above
1304. **Edit:** `ctx_edit(path, old_string, new_string)` or native Edit if available
1315. **Verify:** `ctx_read(path, "diff")` + `ctx_shell("test command")`
1326. **Record:** `ctx_knowledge(action="remember", content="...")` for non-obvious findings
133
134## Proactive (use without being asked)
135- `ctx_overview(task)` — at session start for orientation
136- `ctx_compress` — when context grows large (at phase boundaries)
137- `ctx_knowledge(action="wakeup")` — at session start to surface prior findings
138
139## Compression Bypass (only when compressed output hides needed detail)
140`ctx_read(path, "lines:N-M")` → `ctx_read(path, "full")` → `ctx_shell(cmd, raw=true)`
141Return to compressed defaults after one expanded retrieval.
142
143## Risk Gate (before high-impact edits)
144Before editing exported symbols, auth, DB schemas, or 3+ files: run `ctx_impact(action="analyze")`
145and `ctx_callgraph(action="callers")` to confirm blast radius.
146
147## Session
148- **Start:** `ctx_session(action="status")` + `ctx_knowledge(action="wakeup")`
149- **End:** `ctx_session(action="decision", content="what was done + next steps")`
150- **On [CHECKPOINT]:** `ctx_session(action="task", value="current status")`
151
152NEVER use native Read/Grep/Shell when ctx_* equivalents are available.
153<!-- /lean-ctx -->"#;
154
155// ---------------------------------------------------------------------------
156// Rules for Cursor MDC format (dedicated file with frontmatter).
157// ---------------------------------------------------------------------------
158const RULES_CURSOR_MDC: &str = include_str!("templates/lean-ctx.mdc");
159
160// ---------------------------------------------------------------------------
161
162struct RulesTarget {
163    name: &'static str,
164    path: PathBuf,
165    format: RulesFormat,
166}
167
168enum RulesFormat {
169    SharedMarkdown,
170    DedicatedMarkdown,
171    CursorMdc,
172}
173
174#[derive(Debug, Default)]
175pub struct InjectResult {
176    pub injected: Vec<String>,
177    pub updated: Vec<String>,
178    pub already: Vec<String>,
179    pub errors: Vec<String>,
180    pub backed_up: Vec<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct RulesTargetStatus {
185    pub name: String,
186    pub detected: bool,
187    pub path: String,
188    pub state: String,
189    pub note: Option<String>,
190}
191
192pub fn inject_all_rules(home: &std::path::Path) -> InjectResult {
193    let cfg = crate::core::config::Config::load();
194    if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
195        return InjectResult::default();
196    }
197
198    let targets = build_rules_targets(home, cfg.rules_injection_effective());
199
200    let mut result = InjectResult::default();
201
202    for target in &targets {
203        if !is_tool_detected(target, home) {
204            continue;
205        }
206
207        let bak_path = target.path.with_extension(format!(
208            "{}.bak",
209            target
210                .path
211                .extension()
212                .and_then(|e| e.to_str())
213                .unwrap_or("")
214        ));
215        let bak_existed_before = bak_path.exists();
216        let bak_mtime_before = bak_existed_before
217            .then(|| {
218                std::fs::metadata(&bak_path)
219                    .ok()
220                    .and_then(|m| m.modified().ok())
221            })
222            .flatten();
223
224        match inject_rules(target) {
225            Ok(RulesResult::Injected) => result.injected.push(target.name.to_string()),
226            Ok(RulesResult::Updated) => {
227                result.updated.push(target.name.to_string());
228                let bak_is_new = if bak_existed_before {
229                    std::fs::metadata(&bak_path)
230                        .ok()
231                        .and_then(|m| m.modified().ok())
232                        != bak_mtime_before
233                } else {
234                    bak_path.exists()
235                };
236                if bak_is_new {
237                    result
238                        .backed_up
239                        .push(bak_path.to_string_lossy().to_string());
240                }
241            }
242            Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
243            Err(e) => result.errors.push(format!("{}: {e}", target.name)),
244        }
245    }
246
247    result
248}
249
250/// Inject global rules for a single agent (by CLI key like "opencode", "cursor", etc.).
251/// Used by `init --agent` to ensure global rules are written alongside MCP config.
252pub fn inject_rules_for_agent(home: &std::path::Path, agent_key: &str) -> InjectResult {
253    let cfg = crate::core::config::Config::load();
254    if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
255        return InjectResult::default();
256    }
257
258    let targets = build_rules_targets(home, cfg.rules_injection_effective());
259    let mut result = InjectResult::default();
260
261    for target in &targets {
262        if !match_agent_name(agent_key, target.name) {
263            continue;
264        }
265
266        let bak_path = target.path.with_extension(format!(
267            "{}.bak",
268            target
269                .path
270                .extension()
271                .and_then(|e| e.to_str())
272                .unwrap_or("")
273        ));
274        let bak_existed_before = bak_path.exists();
275
276        match inject_rules(target) {
277            Ok(RulesResult::Injected) => result.injected.push(target.name.to_string()),
278            Ok(RulesResult::Updated) => {
279                result.updated.push(target.name.to_string());
280                if !bak_existed_before && bak_path.exists() {
281                    result
282                        .backed_up
283                        .push(bak_path.to_string_lossy().to_string());
284                }
285            }
286            Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
287            Err(e) => result.errors.push(format!("{}: {e}", target.name)),
288        }
289    }
290
291    result
292}
293
294fn match_agent_name(cli_key: &str, target_name: &str) -> bool {
295    let needle = cli_key.to_lowercase();
296    let tn = target_name.to_lowercase();
297    needle.contains(&tn)
298        || tn.contains(&needle)
299        || (needle.contains("cursor") && tn.contains("cursor"))
300        || (needle.contains("claude") && tn.contains("claude"))
301        || (needle.contains("windsurf") && tn.contains("windsurf"))
302        || (needle.contains("codex") && tn.contains("claude"))
303        || (needle.contains("zed") && tn.contains("zed"))
304        || (needle.contains("copilot") && tn.contains("copilot"))
305        || (needle.contains("jetbrains") && tn.contains("jetbrains"))
306        || (needle.contains("kiro") && tn.contains("kiro"))
307        || (needle.contains("gemini") && tn.contains("gemini"))
308        || (needle == "opencode" && tn.contains("opencode"))
309        || (needle == "cline" && tn.contains("cline"))
310        || (needle == "roo" && tn.contains("roo"))
311        || (needle == "amp" && tn.contains("amp"))
312        || (needle == "trae" && tn.contains("trae"))
313        || (needle == "amazonq" && tn.contains("amazon"))
314        || (needle == "pi" && tn.contains("pi coding"))
315        || (needle == "crush" && tn.contains("crush"))
316        || (needle == "verdent" && tn.contains("verdent"))
317        || (needle == "continue" && tn.contains("continue"))
318        || (needle == "qwen" && tn.contains("qwen"))
319        || (needle == "antigravity" && tn.contains("antigravity"))
320        || (needle == "augment" && tn.contains("augment"))
321        || (needle == "openclaw" && tn.contains("openclaw"))
322        || (needle == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
323}
324
325/// Check if the rules file for a given MCP client is up-to-date.
326/// Returns `Some(message)` if rules are stale/missing, `None` if current.
327pub fn check_rules_freshness(client_name: &str) -> Option<String> {
328    let home = dirs::home_dir()?;
329    let injection = crate::core::config::Config::load().rules_injection_effective();
330    let targets = build_rules_targets(&home, injection);
331
332    let matched: Vec<&RulesTarget> = targets
333        .iter()
334        .filter(|t| match_agent_name(client_name, t.name))
335        .collect();
336
337    if matched.is_empty() {
338        return None;
339    }
340
341    for target in &matched {
342        if !target.path.exists() {
343            continue;
344        }
345        let content = std::fs::read_to_string(&target.path).ok()?;
346        if content.contains(MARKER) && !content.contains(RULES_VERSION) {
347            return Some(format!(
348                "[RULES OUTDATED] Your {} rules were written by an older lean-ctx version. \
349                 Re-read your rules file ({}) or run `lean-ctx setup` to update, \
350                 then start a new session for full compatibility.",
351                target.name,
352                target.path.display()
353            ));
354        }
355    }
356
357    None
358}
359
360pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
361    let injection = crate::core::config::Config::load().rules_injection_effective();
362    let targets = build_rules_targets(home, injection);
363    let mut out = Vec::new();
364
365    for target in &targets {
366        let detected = is_tool_detected(target, home);
367        let path = target.path.to_string_lossy().to_string();
368
369        let state = if !detected {
370            "not_detected".to_string()
371        } else if !target.path.exists() {
372            "missing".to_string()
373        } else {
374            match std::fs::read_to_string(&target.path) {
375                Ok(content) => {
376                    if content.contains(MARKER) {
377                        if content.contains(RULES_VERSION) {
378                            "up_to_date".to_string()
379                        } else {
380                            "outdated".to_string()
381                        }
382                    } else {
383                        "present_without_marker".to_string()
384                    }
385                }
386                Err(_) => "read_error".to_string(),
387            }
388        };
389
390        out.push(RulesTargetStatus {
391            name: target.name.to_string(),
392            detected,
393            path,
394            state,
395            note: None,
396        });
397    }
398
399    out
400}
401
402// ---------------------------------------------------------------------------
403// Injection logic
404// ---------------------------------------------------------------------------
405
406enum RulesResult {
407    Injected,
408    Updated,
409    AlreadyPresent,
410}
411
412fn rules_content(format: &RulesFormat) -> &'static str {
413    match format {
414        RulesFormat::SharedMarkdown => RULES_SHARED,
415        RulesFormat::DedicatedMarkdown => RULES_DEDICATED,
416        RulesFormat::CursorMdc => RULES_CURSOR_MDC,
417    }
418}
419
420fn inject_rules(target: &RulesTarget) -> Result<RulesResult, String> {
421    if target.path.exists() {
422        let content = std::fs::read_to_string(&target.path).map_err(|e| e.to_string())?;
423        if content.contains(MARKER) {
424            if content.contains(RULES_VERSION) {
425                return Ok(RulesResult::AlreadyPresent);
426            }
427            ensure_parent(&target.path)?;
428            return match target.format {
429                RulesFormat::SharedMarkdown => replace_markdown_section(&target.path, &content),
430                RulesFormat::DedicatedMarkdown | RulesFormat::CursorMdc => {
431                    write_dedicated(&target.path, rules_content(&target.format))
432                }
433            };
434        }
435    }
436
437    ensure_parent(&target.path)?;
438
439    match target.format {
440        RulesFormat::SharedMarkdown => append_to_shared(&target.path),
441        RulesFormat::DedicatedMarkdown | RulesFormat::CursorMdc => {
442            write_dedicated(&target.path, rules_content(&target.format))
443        }
444    }
445}
446
447fn ensure_parent(path: &std::path::Path) -> Result<(), String> {
448    if let Some(parent) = path.parent() {
449        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
450    }
451    Ok(())
452}
453
454fn append_to_shared(path: &std::path::Path) -> Result<RulesResult, String> {
455    let mut content = if path.exists() {
456        std::fs::read_to_string(path).map_err(|e| e.to_string())?
457    } else {
458        String::new()
459    };
460
461    if !content.is_empty() && !content.ends_with('\n') {
462        content.push('\n');
463    }
464    if !content.is_empty() {
465        content.push('\n');
466    }
467    content.push_str(RULES_SHARED);
468    content.push('\n');
469
470    crate::config_io::write_atomic_with_backup(path, &content)?;
471    Ok(RulesResult::Injected)
472}
473
474fn replace_markdown_section(path: &std::path::Path, content: &str) -> Result<RulesResult, String> {
475    let start = content.find(MARKER);
476    let end = content.find(END_MARKER);
477
478    let new_content = match (start, end) {
479        (Some(s), Some(e)) => {
480            let before = &content[..s];
481            let after_end = e + END_MARKER.len();
482            let after = content[after_end..].trim_start_matches('\n');
483            let mut result = before.to_string();
484            result.push_str(RULES_SHARED);
485            if !after.is_empty() {
486                result.push('\n');
487                result.push_str(after);
488            }
489            result
490        }
491        (Some(s), None) => {
492            let before = &content[..s];
493            let mut result = before.to_string();
494            result.push_str(RULES_SHARED);
495            result.push('\n');
496            result
497        }
498        _ => return Ok(RulesResult::AlreadyPresent),
499    };
500
501    crate::config_io::write_atomic_with_backup(path, &new_content)?;
502    Ok(RulesResult::Updated)
503}
504
505fn write_dedicated(path: &std::path::Path, content: &'static str) -> Result<RulesResult, String> {
506    if !path.exists() {
507        crate::config_io::write_atomic_with_backup(path, content)?;
508        return Ok(RulesResult::Injected);
509    }
510
511    let existing = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
512    if !existing.contains(MARKER) {
513        crate::config_io::write_atomic_with_backup(path, content)?;
514        return Ok(RulesResult::Injected);
515    }
516
517    let start = existing.find(MARKER);
518    let end = existing.find(END_MARKER);
519
520    let (before, after) = match (start, end) {
521        (Some(s), Some(e)) => {
522            let before = &existing[..s];
523            let after_end = e + END_MARKER.len();
524            let after = existing[after_end..].trim_start_matches('\n');
525            (before.to_string(), after.to_string())
526        }
527        (Some(s), None) => (existing[..s].to_string(), String::new()),
528        _ => (String::new(), String::new()),
529    };
530
531    let has_user_content = !before.trim().is_empty() || !after.trim().is_empty();
532
533    if has_user_content {
534        let new_section = if let Some(marker_pos) = content.find(MARKER) {
535            &content[marker_pos..]
536        } else {
537            content
538        };
539
540        let mut result = before.clone();
541        result.push_str(new_section);
542        if !after.is_empty() {
543            if !result.ends_with('\n') {
544                result.push('\n');
545            }
546            result.push_str(&after);
547        }
548        if !result.ends_with('\n') {
549            result.push('\n');
550        }
551        crate::config_io::write_atomic_with_backup(path, &result)?;
552    } else {
553        crate::config_io::write_atomic_with_backup(path, content)?;
554    }
555
556    Ok(RulesResult::Updated)
557}
558
559// ---------------------------------------------------------------------------
560// Tool detection
561// ---------------------------------------------------------------------------
562
563fn is_tool_detected(target: &RulesTarget, home: &std::path::Path) -> bool {
564    match target.name {
565        "Claude Code" => {
566            if command_exists("claude") {
567                return true;
568            }
569            let state_dir = crate::core::editor_registry::claude_state_dir(home);
570            crate::core::editor_registry::claude_mcp_json_path(home).exists() || state_dir.exists()
571        }
572        "Codex CLI" => {
573            let codex_dir =
574                crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
575            codex_dir.exists() || command_exists("codex")
576        }
577        "Cursor" => home.join(".cursor").exists(),
578        "Windsurf" => home.join(".codeium/windsurf").exists(),
579        "Gemini CLI" => home.join(".gemini").exists(),
580        "VS Code" => detect_vscode_installed(home),
581        "Copilot CLI" => home.join(".copilot").exists() || command_exists("copilot"),
582        "Zed" => crate::core::editor_registry::zed_config_dir(home).exists(),
583        "Cline" => detect_extension_installed(home, "saoudrizwan.claude-dev"),
584        "Roo Code" => detect_extension_installed(home, "rooveterinaryinc.roo-cline"),
585        "OpenCode" => home.join(".config/opencode").exists(),
586        "Continue" => detect_extension_installed(home, "continue.continue"),
587        "Amp" => command_exists("amp") || home.join(".ampcoder").exists(),
588        "Qwen Code" => home.join(".qwen").exists(),
589        "Trae" => home.join(".trae").exists(),
590        "Amazon Q Developer" => home.join(".aws/amazonq").exists(),
591        "JetBrains IDEs" => detect_jetbrains_installed(home),
592        "Antigravity" => home.join(".gemini/antigravity").exists(),
593        "Pi Coding Agent" => home.join(".pi").exists() || command_exists("pi"),
594        "AWS Kiro" => home.join(".kiro").exists(),
595        "Crush" => home.join(".config/crush").exists() || command_exists("crush"),
596        "Verdent" => home.join(".verdent").exists(),
597        // Augment ships as either the `auggie` CLI (writes to ~/.augment/) or
598        // the VS Code extension (`augment.vscode-augment` globalStorage).
599        "Augment" => {
600            command_exists("auggie")
601                || home.join(".augment").exists()
602                || detect_extension_installed(home, "augment.vscode-augment")
603        }
604        _ => false,
605    }
606}
607
608fn command_exists(name: &str) -> bool {
609    #[cfg(target_os = "windows")]
610    let result = std::process::Command::new("where")
611        .arg(name)
612        .output()
613        .is_ok_and(|o| o.status.success());
614
615    #[cfg(not(target_os = "windows"))]
616    let result = std::process::Command::new("which")
617        .arg(name)
618        .output()
619        .is_ok_and(|o| o.status.success());
620
621    result
622}
623
624fn detect_vscode_installed(_home: &std::path::Path) -> bool {
625    let check_dir = |dir: PathBuf| -> bool {
626        dir.join("settings.json").exists() || dir.join("mcp.json").exists()
627    };
628
629    #[cfg(target_os = "macos")]
630    if check_dir(_home.join("Library/Application Support/Code/User")) {
631        return true;
632    }
633    #[cfg(target_os = "linux")]
634    if check_dir(_home.join(".config/Code/User")) {
635        return true;
636    }
637    #[cfg(target_os = "windows")]
638    if let Ok(appdata) = std::env::var("APPDATA") {
639        if check_dir(PathBuf::from(&appdata).join("Code/User")) {
640            return true;
641        }
642    }
643    false
644}
645
646fn detect_jetbrains_installed(home: &std::path::Path) -> bool {
647    #[cfg(target_os = "macos")]
648    if home.join("Library/Application Support/JetBrains").exists() {
649        return true;
650    }
651    #[cfg(target_os = "linux")]
652    if home.join(".config/JetBrains").exists() {
653        return true;
654    }
655    home.join(".jb-mcp.json").exists()
656}
657
658fn detect_extension_installed(_home: &std::path::Path, extension_id: &str) -> bool {
659    #[cfg(target_os = "macos")]
660    {
661        if _home
662            .join(format!(
663                "Library/Application Support/Code/User/globalStorage/{extension_id}"
664            ))
665            .exists()
666        {
667            return true;
668        }
669    }
670    #[cfg(target_os = "linux")]
671    {
672        if _home
673            .join(format!(".config/Code/User/globalStorage/{extension_id}"))
674            .exists()
675        {
676            return true;
677        }
678    }
679    #[cfg(target_os = "windows")]
680    {
681        if let Ok(appdata) = std::env::var("APPDATA") {
682            if std::path::PathBuf::from(&appdata)
683                .join(format!("Code/User/globalStorage/{extension_id}"))
684                .exists()
685            {
686                return true;
687            }
688        }
689    }
690    false
691}
692
693// ---------------------------------------------------------------------------
694// Target definitions
695// ---------------------------------------------------------------------------
696
697fn build_rules_targets(
698    home: &std::path::Path,
699    injection: crate::core::config::RulesInjection,
700) -> Vec<RulesTarget> {
701    use crate::core::config::RulesInjection;
702
703    // In dedicated mode the two AGENTS.md/GEMINI.md consumers write to a
704    // lean-ctx-owned file instead of the user's shared instruction file;
705    // discovery is wired up separately via opencode.json instructions[] /
706    // .gemini/settings.json context.fileName (#343).
707    let (gemini_path, gemini_format) = match injection {
708        RulesInjection::Dedicated => (
709            gemini_dedicated_rules_path(home),
710            RulesFormat::DedicatedMarkdown,
711        ),
712        RulesInjection::Shared => (home.join(".gemini/GEMINI.md"), RulesFormat::SharedMarkdown),
713    };
714    let (opencode_path, opencode_format) = match injection {
715        RulesInjection::Dedicated => (
716            opencode_dedicated_rules_path(home),
717            RulesFormat::DedicatedMarkdown,
718        ),
719        RulesInjection::Shared => (
720            home.join(".config/opencode/AGENTS.md"),
721            RulesFormat::SharedMarkdown,
722        ),
723    };
724
725    vec![
726        // --- Shared config files (append-only) ---
727        RulesTarget {
728            name: "Claude Code",
729            path: crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
730            format: RulesFormat::DedicatedMarkdown,
731        },
732        RulesTarget {
733            name: "Gemini CLI",
734            path: gemini_path,
735            format: gemini_format,
736        },
737        RulesTarget {
738            name: "VS Code",
739            path: copilot_instructions_path(home),
740            format: RulesFormat::SharedMarkdown,
741        },
742        RulesTarget {
743            name: "Copilot CLI",
744            path: home.join(".copilot/instructions.md"),
745            format: RulesFormat::SharedMarkdown,
746        },
747        // --- Dedicated lean-ctx rule files ---
748        RulesTarget {
749            name: "Cursor",
750            path: home.join(".cursor/rules/lean-ctx.mdc"),
751            format: RulesFormat::CursorMdc,
752        },
753        RulesTarget {
754            name: "Windsurf",
755            path: home.join(".codeium/windsurf/rules/lean-ctx.md"),
756            format: RulesFormat::DedicatedMarkdown,
757        },
758        RulesTarget {
759            name: "Zed",
760            // OS-aware: Zed's config dir is platform-specific (macOS uses
761            // Application Support); keep rules co-located with the MCP config.
762            path: crate::core::editor_registry::zed_config_dir(home).join("rules/lean-ctx.md"),
763            format: RulesFormat::DedicatedMarkdown,
764        },
765        RulesTarget {
766            name: "Cline",
767            path: home.join(".cline/rules/lean-ctx.md"),
768            format: RulesFormat::DedicatedMarkdown,
769        },
770        RulesTarget {
771            name: "Roo Code",
772            path: home.join(".roo/rules/lean-ctx.md"),
773            format: RulesFormat::DedicatedMarkdown,
774        },
775        RulesTarget {
776            name: "OpenCode",
777            path: opencode_path,
778            format: opencode_format,
779        },
780        RulesTarget {
781            name: "Continue",
782            path: home.join(".continue/rules/lean-ctx.md"),
783            format: RulesFormat::DedicatedMarkdown,
784        },
785        RulesTarget {
786            name: "Amp",
787            path: home.join(".ampcoder/rules/lean-ctx.md"),
788            format: RulesFormat::DedicatedMarkdown,
789        },
790        RulesTarget {
791            name: "Qwen Code",
792            path: home.join(".qwen/rules/lean-ctx.md"),
793            format: RulesFormat::DedicatedMarkdown,
794        },
795        RulesTarget {
796            name: "Trae",
797            path: home.join(".trae/rules/lean-ctx.md"),
798            format: RulesFormat::DedicatedMarkdown,
799        },
800        RulesTarget {
801            name: "Amazon Q Developer",
802            path: home.join(".aws/amazonq/rules/lean-ctx.md"),
803            format: RulesFormat::DedicatedMarkdown,
804        },
805        RulesTarget {
806            name: "JetBrains IDEs",
807            path: home.join(".jb-rules/lean-ctx.md"),
808            format: RulesFormat::DedicatedMarkdown,
809        },
810        RulesTarget {
811            name: "Antigravity",
812            path: home.join(".gemini/antigravity/rules/lean-ctx.md"),
813            format: RulesFormat::DedicatedMarkdown,
814        },
815        RulesTarget {
816            name: "Pi Coding Agent",
817            path: home.join(".pi/rules/lean-ctx.md"),
818            format: RulesFormat::DedicatedMarkdown,
819        },
820        RulesTarget {
821            name: "AWS Kiro",
822            path: home.join(".kiro/steering/lean-ctx.md"),
823            format: RulesFormat::DedicatedMarkdown,
824        },
825        RulesTarget {
826            name: "Verdent",
827            path: home.join(".verdent/rules/lean-ctx.md"),
828            format: RulesFormat::DedicatedMarkdown,
829        },
830        RulesTarget {
831            name: "Crush",
832            path: home.join(".config/crush/rules/lean-ctx.md"),
833            format: RulesFormat::DedicatedMarkdown,
834        },
835        RulesTarget {
836            name: "Augment",
837            path: home.join(".augment/rules/lean-ctx.md"),
838            format: RulesFormat::DedicatedMarkdown,
839        },
840        RulesTarget {
841            name: "OpenClaw",
842            path: home.join(".openclaw/rules/lean-ctx.md"),
843            format: RulesFormat::DedicatedMarkdown,
844        },
845    ]
846}
847
848fn copilot_instructions_path(home: &std::path::Path) -> PathBuf {
849    #[cfg(target_os = "macos")]
850    {
851        return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
852    }
853    #[cfg(target_os = "linux")]
854    {
855        return home.join(".config/Code/User/github-copilot-instructions.md");
856    }
857    #[cfg(target_os = "windows")]
858    {
859        if let Ok(appdata) = std::env::var("APPDATA") {
860            return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
861        }
862    }
863    #[allow(unreachable_code)]
864    home.join(".config/Code/User/github-copilot-instructions.md")
865}
866
867// ---------------------------------------------------------------------------
868// SKILL.md installation
869// ---------------------------------------------------------------------------
870
871const SKILL_TEMPLATE: &str = include_str!("templates/SKILL.md");
872
873struct SkillTarget {
874    agent_key: &'static str,
875    display_name: &'static str,
876    skill_dir: PathBuf,
877}
878
879fn build_skill_targets(home: &std::path::Path) -> Vec<SkillTarget> {
880    vec![
881        SkillTarget {
882            agent_key: "claude",
883            display_name: "Claude Code",
884            skill_dir: crate::setup::claude_config_dir(home).join("skills/lean-ctx"),
885        },
886        SkillTarget {
887            agent_key: "cursor",
888            display_name: "Cursor",
889            skill_dir: home.join(".cursor/skills/lean-ctx"),
890        },
891        SkillTarget {
892            agent_key: "codex",
893            display_name: "Codex CLI",
894            skill_dir: crate::core::home::resolve_codex_dir()
895                .unwrap_or_else(|| home.join(".codex"))
896                .join("skills/lean-ctx"),
897        },
898        SkillTarget {
899            agent_key: "copilot",
900            display_name: "GitHub Copilot",
901            skill_dir: home.join(".copilot/skills/lean-ctx"),
902        },
903        SkillTarget {
904            agent_key: "openclaw",
905            display_name: "OpenClaw",
906            skill_dir: home.join(".openclaw/skills/lean-ctx"),
907        },
908    ]
909}
910
911fn is_skill_agent_detected(agent_key: &str, home: &std::path::Path) -> bool {
912    match agent_key {
913        "claude" => {
914            command_exists("claude")
915                || crate::core::editor_registry::claude_mcp_json_path(home).exists()
916                || crate::core::editor_registry::claude_state_dir(home).exists()
917        }
918        "cursor" => home.join(".cursor").exists(),
919        "codex" => {
920            let codex_dir =
921                crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
922            codex_dir.exists() || command_exists("codex")
923        }
924        "copilot" => {
925            home.join(".copilot").exists()
926                || home.join(".copilot/mcp-config.json").exists()
927                || command_exists("copilot")
928        }
929        "openclaw" => home.join(".openclaw").exists() || command_exists("openclaw"),
930        _ => false,
931    }
932}
933
934/// Install SKILL.md for a specific agent. Returns the installed path.
935pub fn install_skill_for_agent(home: &std::path::Path, agent_key: &str) -> Result<PathBuf, String> {
936    let targets = build_skill_targets(home);
937    let target = targets
938        .into_iter()
939        .find(|t| t.agent_key == agent_key)
940        .ok_or_else(|| format!("No skill target for agent '{agent_key}'"))?;
941
942    let skill_path = target.skill_dir.join("SKILL.md");
943    std::fs::create_dir_all(&target.skill_dir).map_err(|e| e.to_string())?;
944
945    if skill_path.exists() {
946        let existing = std::fs::read_to_string(&skill_path).unwrap_or_default();
947        if existing == SKILL_TEMPLATE {
948            return Ok(skill_path);
949        }
950    }
951
952    crate::config_io::write_atomic_with_backup(&skill_path, SKILL_TEMPLATE)?;
953    Ok(skill_path)
954}
955
956/// Install SKILL.md for all detected agents.
957/// Returns `Vec<(display_name, was_new_or_updated)>`.
958pub fn install_all_skills(home: &std::path::Path) -> Vec<(String, bool)> {
959    let targets = build_skill_targets(home);
960    let mut results = Vec::new();
961
962    for target in &targets {
963        if !is_skill_agent_detected(target.agent_key, home) {
964            continue;
965        }
966
967        let skill_path = target.skill_dir.join("SKILL.md");
968        let already_current = skill_path.exists()
969            && std::fs::read_to_string(&skill_path).is_ok_and(|c| c == SKILL_TEMPLATE);
970
971        if already_current {
972            results.push((target.display_name.to_string(), false));
973            continue;
974        }
975
976        if let Err(e) = std::fs::create_dir_all(&target.skill_dir) {
977            tracing::warn!(
978                "Failed to create skill dir for {}: {e}",
979                target.display_name
980            );
981            continue;
982        }
983
984        match crate::config_io::write_atomic_with_backup(&skill_path, SKILL_TEMPLATE) {
985            Ok(()) => results.push((target.display_name.to_string(), true)),
986            Err(e) => {
987                tracing::warn!("Failed to write SKILL.md for {}: {e}", target.display_name);
988            }
989        }
990    }
991
992    results
993}
994
995// ---------------------------------------------------------------------------
996// Tests
997// ---------------------------------------------------------------------------
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002
1003    #[test]
1004    fn shared_rules_have_markers() {
1005        assert!(RULES_SHARED.contains(MARKER));
1006        assert!(RULES_SHARED.contains(END_MARKER));
1007        assert!(RULES_SHARED.contains(RULES_VERSION));
1008    }
1009
1010    #[test]
1011    fn zed_rules_path_is_os_aware_and_matches_config_dir() {
1012        // Zed's config dir is platform-specific (macOS uses Application Support).
1013        // Rules must live under the SAME dir as the MCP config, never a hardcoded
1014        // ~/.config/zed on every OS (regression: rules missed on macOS).
1015        let home = std::path::Path::new("/home/tester");
1016        let zed = build_rules_targets(home, crate::core::config::RulesInjection::Shared)
1017            .into_iter()
1018            .find(|t| t.name == "Zed")
1019            .expect("Zed rules target must exist");
1020        let expected = crate::core::editor_registry::zed_config_dir(home).join("rules/lean-ctx.md");
1021        assert_eq!(zed.path, expected);
1022    }
1023
1024    #[test]
1025    fn dedicated_rules_have_markers() {
1026        assert!(RULES_DEDICATED.contains(MARKER));
1027        assert!(RULES_DEDICATED.contains(END_MARKER));
1028        assert!(RULES_DEDICATED.contains(RULES_VERSION));
1029    }
1030
1031    #[test]
1032    fn cursor_mdc_has_markers_and_frontmatter() {
1033        assert!(RULES_CURSOR_MDC.contains("lean-ctx"));
1034        assert!(RULES_CURSOR_MDC.contains(END_MARKER));
1035        assert!(RULES_CURSOR_MDC.contains(RULES_VERSION));
1036        assert!(RULES_CURSOR_MDC.contains("alwaysApply: true"));
1037    }
1038
1039    #[test]
1040    fn shared_rules_contain_mode_selection() {
1041        assert!(RULES_SHARED.contains("Mode Selection"));
1042        assert!(RULES_SHARED.contains("full"));
1043        assert!(RULES_SHARED.contains("map"));
1044        assert!(RULES_SHARED.contains("signatures"));
1045        assert!(RULES_SHARED.contains("NEVER"));
1046    }
1047
1048    #[test]
1049    fn shared_rules_has_never_native() {
1050        assert!(RULES_SHARED.contains("NEVER use native"));
1051        assert!(RULES_SHARED.contains("ctx_read"));
1052    }
1053
1054    #[test]
1055    fn dedicated_rules_contain_modes() {
1056        assert!(RULES_DEDICATED.contains("auto"));
1057        assert!(RULES_DEDICATED.contains("full"));
1058        assert!(RULES_DEDICATED.contains("map"));
1059        assert!(RULES_DEDICATED.contains("signatures"));
1060        assert!(RULES_DEDICATED.contains("lines:N-M"));
1061        assert!(RULES_DEDICATED.contains("diff"));
1062    }
1063
1064    #[test]
1065    fn dedicated_rules_has_proactive_section() {
1066        assert!(RULES_DEDICATED.contains("Proactive"));
1067        assert!(RULES_DEDICATED.contains("ctx_overview"));
1068        assert!(RULES_DEDICATED.contains("ctx_compress"));
1069    }
1070
1071    #[test]
1072    fn cursor_mdc_contains_tool_mapping() {
1073        assert!(RULES_CURSOR_MDC.contains("Tool Mapping"));
1074        assert!(RULES_CURSOR_MDC.contains("ctx_read"));
1075        assert!(RULES_CURSOR_MDC.contains("ctx_search"));
1076        assert!(RULES_CURSOR_MDC.contains("Workflow"));
1077    }
1078
1079    fn ensure_temp_dir() {
1080        let tmp = std::env::temp_dir();
1081        if !tmp.exists() {
1082            std::fs::create_dir_all(&tmp).ok();
1083        }
1084    }
1085
1086    #[test]
1087    fn replace_section_with_end_marker() {
1088        ensure_temp_dir();
1089        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";
1090        let path = std::env::temp_dir().join("test_replace_with_end.md");
1091        std::fs::write(&path, old).unwrap();
1092
1093        let result = replace_markdown_section(&path, old).unwrap();
1094        assert!(matches!(result, RulesResult::Updated));
1095
1096        let new_content = std::fs::read_to_string(&path).unwrap();
1097        assert!(new_content.contains(RULES_VERSION));
1098        assert!(new_content.starts_with("user stuff"));
1099        assert!(new_content.contains("more user stuff"));
1100        assert!(!new_content.contains("lean-ctx-rules-v2"));
1101
1102        std::fs::remove_file(&path).ok();
1103    }
1104
1105    #[test]
1106    fn replace_section_without_end_marker() {
1107        ensure_temp_dir();
1108        let old = "user stuff\n\n# lean-ctx — Context Engineering Layer\nold rules only\n";
1109        let path = std::env::temp_dir().join("test_replace_no_end.md");
1110        std::fs::write(&path, old).unwrap();
1111
1112        let result = replace_markdown_section(&path, old).unwrap();
1113        assert!(matches!(result, RulesResult::Updated));
1114
1115        let new_content = std::fs::read_to_string(&path).unwrap();
1116        assert!(new_content.contains(RULES_VERSION));
1117        assert!(new_content.starts_with("user stuff"));
1118
1119        std::fs::remove_file(&path).ok();
1120    }
1121
1122    #[test]
1123    fn append_to_shared_preserves_existing() {
1124        ensure_temp_dir();
1125        let path = std::env::temp_dir().join("test_append_shared.md");
1126        std::fs::write(&path, "existing user rules\n").unwrap();
1127
1128        let result = append_to_shared(&path).unwrap();
1129        assert!(matches!(result, RulesResult::Injected));
1130
1131        let content = std::fs::read_to_string(&path).unwrap();
1132        assert!(content.starts_with("existing user rules"));
1133        assert!(content.contains(MARKER));
1134        assert!(content.contains(END_MARKER));
1135
1136        std::fs::remove_file(&path).ok();
1137    }
1138
1139    #[test]
1140    fn write_dedicated_creates_file() {
1141        ensure_temp_dir();
1142        let path = std::env::temp_dir().join("test_write_dedicated.md");
1143        if path.exists() {
1144            std::fs::remove_file(&path).ok();
1145        }
1146
1147        let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
1148        assert!(matches!(result, RulesResult::Injected));
1149
1150        let content = std::fs::read_to_string(&path).unwrap();
1151        assert!(content.contains(MARKER));
1152        assert!(content.contains("Mode Selection"));
1153
1154        std::fs::remove_file(&path).ok();
1155    }
1156
1157    #[test]
1158    fn write_dedicated_updates_existing() {
1159        ensure_temp_dir();
1160        let path = std::env::temp_dir().join("test_write_dedicated_update.md");
1161        std::fs::write(&path, "# lean-ctx — Context Engineering Layer\nold version").unwrap();
1162
1163        let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
1164        assert!(matches!(result, RulesResult::Updated));
1165
1166        std::fs::remove_file(&path).ok();
1167    }
1168
1169    #[test]
1170    fn target_count() {
1171        let home = std::path::PathBuf::from("/tmp/fake_home");
1172        let targets = build_rules_targets(&home, crate::core::config::RulesInjection::Shared);
1173        assert_eq!(targets.len(), 23);
1174        // Dedicated mode swaps paths/formats but never changes the target count.
1175        let dedicated = build_rules_targets(&home, crate::core::config::RulesInjection::Dedicated);
1176        assert_eq!(dedicated.len(), 23);
1177    }
1178
1179    #[test]
1180    fn dedicated_mode_swaps_shared_agents_to_dedicated_files() {
1181        use crate::core::config::RulesInjection;
1182        let home = std::path::Path::new("/home/tester");
1183
1184        let shared = build_rules_targets(home, RulesInjection::Shared);
1185        let gemini_shared = shared.iter().find(|t| t.name == "Gemini CLI").unwrap();
1186        let opencode_shared = shared.iter().find(|t| t.name == "OpenCode").unwrap();
1187        assert!(matches!(gemini_shared.format, RulesFormat::SharedMarkdown));
1188        assert!(gemini_shared.path.ends_with("GEMINI.md"));
1189        assert!(matches!(
1190            opencode_shared.format,
1191            RulesFormat::SharedMarkdown
1192        ));
1193        assert!(opencode_shared.path.ends_with("AGENTS.md"));
1194
1195        let dedicated = build_rules_targets(home, RulesInjection::Dedicated);
1196        let gemini = dedicated.iter().find(|t| t.name == "Gemini CLI").unwrap();
1197        let opencode = dedicated.iter().find(|t| t.name == "OpenCode").unwrap();
1198        // Never the user's shared instruction file in dedicated mode.
1199        assert!(matches!(gemini.format, RulesFormat::DedicatedMarkdown));
1200        assert_eq!(gemini.path, gemini_dedicated_rules_path(home));
1201        assert!(!gemini.path.ends_with("GEMINI.md"));
1202        assert!(matches!(opencode.format, RulesFormat::DedicatedMarkdown));
1203        assert_eq!(opencode.path, opencode_dedicated_rules_path(home));
1204        assert!(!opencode.path.ends_with("AGENTS.md"));
1205    }
1206
1207    #[test]
1208    fn dedicated_session_summary_is_clean_and_agent_agnostic() {
1209        let s = dedicated_session_summary();
1210        assert!(s.contains("ctx_read"));
1211        assert!(s.contains("ctx_shell"));
1212        assert!(s.contains("ctx_search"));
1213        // Must not carry HTML markers or an @import pointer (Codex has no @import).
1214        assert!(!s.contains("<!--"));
1215        assert!(!s.contains('@'));
1216    }
1217
1218    #[test]
1219    fn skill_template_not_empty() {
1220        assert!(!SKILL_TEMPLATE.is_empty());
1221        assert!(SKILL_TEMPLATE.contains("lean-ctx"));
1222    }
1223
1224    #[test]
1225    fn skill_targets_count() {
1226        let home = std::path::PathBuf::from("/tmp/fake_home");
1227        let targets = build_skill_targets(&home);
1228        assert_eq!(targets.len(), 5);
1229    }
1230
1231    #[test]
1232    fn install_skill_creates_file() {
1233        ensure_temp_dir();
1234        let home = std::env::temp_dir().join("test_skill_install");
1235        let _ = std::fs::create_dir_all(&home);
1236
1237        let fake_cursor = home.join(".cursor");
1238        let _ = std::fs::create_dir_all(&fake_cursor);
1239
1240        let result = install_skill_for_agent(&home, "cursor");
1241        assert!(result.is_ok());
1242
1243        let path = result.unwrap();
1244        assert!(path.exists());
1245        let content = std::fs::read_to_string(&path).unwrap();
1246        assert_eq!(content, SKILL_TEMPLATE);
1247
1248        let _ = std::fs::remove_dir_all(&home);
1249    }
1250
1251    #[test]
1252    fn install_skill_idempotent() {
1253        ensure_temp_dir();
1254        let home = std::env::temp_dir().join("test_skill_idempotent");
1255        let _ = std::fs::create_dir_all(&home);
1256
1257        let fake_cursor = home.join(".cursor");
1258        let _ = std::fs::create_dir_all(&fake_cursor);
1259
1260        let p1 = install_skill_for_agent(&home, "cursor").unwrap();
1261        let p2 = install_skill_for_agent(&home, "cursor").unwrap();
1262        assert_eq!(p1, p2);
1263
1264        let _ = std::fs::remove_dir_all(&home);
1265    }
1266
1267    #[test]
1268    fn install_skill_unknown_agent() {
1269        let home = std::path::PathBuf::from("/tmp/fake_home");
1270        let result = install_skill_for_agent(&home, "unknown_agent");
1271        assert!(result.is_err());
1272    }
1273
1274    #[test]
1275    fn match_agent_name_basic() {
1276        assert!(match_agent_name("cursor", "Cursor"));
1277        assert!(match_agent_name("opencode", "OpenCode"));
1278        assert!(match_agent_name("claude", "Claude Code"));
1279        assert!(match_agent_name("vscode", "VS Code"));
1280        assert!(match_agent_name("copilot", "Copilot CLI"));
1281        assert!(match_agent_name("kiro", "AWS Kiro"));
1282        assert!(match_agent_name("pi", "Pi Coding Agent"));
1283        assert!(match_agent_name("crush", "Crush"));
1284        assert!(match_agent_name("amp", "Amp"));
1285        assert!(match_agent_name("cline", "Cline"));
1286        assert!(match_agent_name("roo", "Roo Code"));
1287        assert!(match_agent_name("trae", "Trae"));
1288        assert!(match_agent_name("amazonq", "Amazon Q Developer"));
1289        assert!(match_agent_name("verdent", "Verdent"));
1290        assert!(match_agent_name("continue", "Continue"));
1291        assert!(match_agent_name("antigravity", "Antigravity"));
1292        assert!(match_agent_name("gemini", "Gemini CLI"));
1293        assert!(match_agent_name("augment", "Augment"));
1294        assert!(match_agent_name("openclaw", "OpenClaw"));
1295    }
1296
1297    #[test]
1298    fn match_agent_name_no_false_positives() {
1299        assert!(!match_agent_name("cursor", "Claude Code"));
1300        assert!(!match_agent_name("opencode", "Cursor"));
1301        assert!(!match_agent_name("unknown_agent", "Cursor"));
1302    }
1303
1304    #[test]
1305    fn inject_rules_for_agent_opencode() {
1306        ensure_temp_dir();
1307        let home = std::env::temp_dir().join("test_inject_rules_agent");
1308        let _ = std::fs::remove_dir_all(&home);
1309        let _ = std::fs::create_dir_all(&home);
1310
1311        let opencode_dir = home.join(".config/opencode");
1312        let _ = std::fs::create_dir_all(&opencode_dir);
1313
1314        let result = inject_rules_for_agent(&home, "opencode");
1315        assert!(
1316            !result.injected.is_empty() || !result.already.is_empty(),
1317            "should inject or find rules for OpenCode"
1318        );
1319        assert!(result.errors.is_empty(), "no errors expected");
1320
1321        let agents_md = opencode_dir.join("AGENTS.md");
1322        if agents_md.exists() {
1323            let content = std::fs::read_to_string(&agents_md).unwrap();
1324            assert!(content.contains(RULES_VERSION));
1325        }
1326
1327        let _ = std::fs::remove_dir_all(&home);
1328    }
1329
1330    #[test]
1331    fn inject_rules_for_agent_cursor() {
1332        ensure_temp_dir();
1333        let home = std::env::temp_dir().join("test_inject_rules_cursor");
1334        let _ = std::fs::remove_dir_all(&home);
1335        let _ = std::fs::create_dir_all(&home);
1336
1337        let cursor_dir = home.join(".cursor");
1338        let _ = std::fs::create_dir_all(&cursor_dir);
1339
1340        let result = inject_rules_for_agent(&home, "cursor");
1341        assert!(result.errors.is_empty(), "no errors expected");
1342
1343        let mdc_path = home.join(".cursor/rules/lean-ctx.mdc");
1344        if mdc_path.exists() {
1345            let content = std::fs::read_to_string(&mdc_path).unwrap();
1346            assert!(content.contains(RULES_VERSION));
1347        }
1348
1349        let _ = std::fs::remove_dir_all(&home);
1350    }
1351
1352    #[test]
1353    fn inject_rules_for_unknown_agent_is_empty() {
1354        let home = std::path::PathBuf::from("/tmp/fake_home_unknown");
1355        let result = inject_rules_for_agent(&home, "unknown_agent_xyz");
1356        assert!(result.injected.is_empty());
1357        assert!(result.updated.is_empty());
1358        assert!(result.already.is_empty());
1359        assert!(result.errors.is_empty());
1360    }
1361
1362    #[test]
1363    fn write_dedicated_preserves_user_content_before_marker() {
1364        ensure_temp_dir();
1365        let path = std::env::temp_dir().join("test_dedicated_preserve_before.md");
1366        let old = format!(
1367            "# My custom rules\nDo not delete this!\n\n{MARKER}\n<!-- lean-ctx-rules-v2 -->\nold content\n{END_MARKER}"
1368        );
1369        std::fs::write(&path, &old).unwrap();
1370
1371        let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
1372        assert!(matches!(result, RulesResult::Updated));
1373
1374        let content = std::fs::read_to_string(&path).unwrap();
1375        assert!(
1376            content.contains("My custom rules"),
1377            "user content before marker must be preserved"
1378        );
1379        assert!(
1380            content.contains("Do not delete this!"),
1381            "user content before marker must be preserved"
1382        );
1383        assert!(
1384            content.contains(RULES_VERSION),
1385            "new rules version must be present"
1386        );
1387        assert!(
1388            !content.contains("lean-ctx-rules-v2"),
1389            "old version must be replaced"
1390        );
1391
1392        std::fs::remove_file(&path).ok();
1393    }
1394
1395    #[test]
1396    fn write_dedicated_preserves_user_content_after_marker() {
1397        ensure_temp_dir();
1398        let path = std::env::temp_dir().join("test_dedicated_preserve_after.md");
1399        let old = format!(
1400            "{MARKER}\n<!-- lean-ctx-rules-v2 -->\nold content\n{END_MARKER}\n\n# User's extra notes\nKeep this too!\n"
1401        );
1402        std::fs::write(&path, &old).unwrap();
1403
1404        let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
1405        assert!(matches!(result, RulesResult::Updated));
1406
1407        let content = std::fs::read_to_string(&path).unwrap();
1408        assert!(
1409            content.contains("User's extra notes"),
1410            "user content after marker must be preserved"
1411        );
1412        assert!(
1413            content.contains("Keep this too!"),
1414            "user content after marker must be preserved"
1415        );
1416        assert!(
1417            content.contains(RULES_VERSION),
1418            "new rules version must be present"
1419        );
1420
1421        std::fs::remove_file(&path).ok();
1422    }
1423
1424    #[test]
1425    fn write_dedicated_preserves_content_both_sides() {
1426        ensure_temp_dir();
1427        let path = std::env::temp_dir().join("test_dedicated_preserve_both.md");
1428        let old = format!(
1429            "BEFORE CONTENT\n\n{MARKER}\n<!-- lean-ctx-rules-v2 -->\nold\n{END_MARKER}\n\nAFTER CONTENT\n"
1430        );
1431        std::fs::write(&path, &old).unwrap();
1432
1433        let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
1434        assert!(matches!(result, RulesResult::Updated));
1435
1436        let content = std::fs::read_to_string(&path).unwrap();
1437        assert!(content.contains("BEFORE CONTENT"));
1438        assert!(content.contains("AFTER CONTENT"));
1439        assert!(content.contains(RULES_VERSION));
1440
1441        std::fs::remove_file(&path).ok();
1442    }
1443
1444    #[test]
1445    fn write_dedicated_no_user_content_uses_template_directly() {
1446        ensure_temp_dir();
1447        let path = std::env::temp_dir().join("test_dedicated_no_user.md");
1448        let old = format!("{MARKER}\n<!-- lean-ctx-rules-v2 -->\nold content\n{END_MARKER}");
1449        std::fs::write(&path, &old).unwrap();
1450
1451        let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
1452        assert!(matches!(result, RulesResult::Updated));
1453
1454        let content = std::fs::read_to_string(&path).unwrap();
1455        assert_eq!(
1456            content, RULES_DEDICATED,
1457            "without user content, template should be written as-is"
1458        );
1459
1460        std::fs::remove_file(&path).ok();
1461    }
1462
1463    #[test]
1464    fn write_dedicated_preserves_mdc_frontmatter() {
1465        ensure_temp_dir();
1466        let path = std::env::temp_dir().join("test_dedicated_mdc_frontmatter.mdc");
1467        let old = format!(
1468            "---\ndescription: custom\nglobs: **/*\nalwaysApply: true\n---\n\nUser preamble here\n\n{MARKER}\n<!-- lean-ctx-rules-v2 -->\nold\n{END_MARKER}\n"
1469        );
1470        std::fs::write(&path, &old).unwrap();
1471
1472        let result = write_dedicated(&path, RULES_CURSOR_MDC).unwrap();
1473        assert!(matches!(result, RulesResult::Updated));
1474
1475        let content = std::fs::read_to_string(&path).unwrap();
1476        assert!(
1477            content.contains("User preamble here"),
1478            "user preamble must be preserved"
1479        );
1480        assert!(
1481            content.contains("custom"),
1482            "user frontmatter description must be preserved"
1483        );
1484        assert!(content.contains(RULES_VERSION));
1485
1486        std::fs::remove_file(&path).ok();
1487    }
1488
1489    #[test]
1490    fn inject_result_tracks_backed_up_files() {
1491        let result = InjectResult {
1492            backed_up: vec!["/tmp/test.md.bak".to_string()],
1493            ..Default::default()
1494        };
1495        assert_eq!(result.backed_up.len(), 1);
1496        assert!(std::path::Path::new(&result.backed_up[0])
1497            .extension()
1498            .is_some_and(|ext| ext.eq_ignore_ascii_case("bak")));
1499    }
1500}