1use std::path::PathBuf;
2
3const MARKER: &str = "# lean-ctx — Context Engineering Layer";
4
5const RULES_MD: &str = r#"# lean-ctx — Context Engineering Layer
6
7Always use lean-ctx MCP tools instead of native equivalents:
8- ctx_read instead of Read/cat for file reads
9- ctx_shell instead of Shell/bash for commands
10- ctx_search instead of Grep/search for code search
11- ctx_tree instead of ls/find for directory listing
12
13Keep using native tools for: Write, StrReplace, Delete, Glob (no lean-ctx replacement).
14Do NOT fall back to native tools for reading, shell, search, or tree operations."#;
15
16const RULES_CURSORRULES: &str = r#"# lean-ctx — Context Engineering Layer
17
18Always use lean-ctx MCP tools. Never use the standard equivalents.
19
20## Tool Mapping
21
22| Instead of | Use |
23|---|-----|
24| `Read` | `ctx_read` |
25| `Shell` | `ctx_shell` |
26| `Grep` | `ctx_search` |
27| `ls`, `find` | `ctx_tree` |
28
29The `Write`, `StrReplace`, `Delete`, `Glob` tools have no lean-ctx equivalent — use them normally."#;
30
31struct RulesTarget {
32 name: &'static str,
33 path: PathBuf,
34 format: RulesFormat,
35}
36
37enum RulesFormat {
38 Markdown,
39 CursorMdc,
40}
41
42pub fn inject_all_rules(home: &std::path::Path) -> (Vec<String>, Vec<String>, Vec<String>) {
43 let targets = build_rules_targets(home);
44
45 let mut injected = Vec::new();
46 let mut already = Vec::new();
47 let mut errors = Vec::new();
48
49 for target in &targets {
50 if !is_tool_detected(target, home) {
51 continue;
52 }
53
54 match inject_rules(target) {
55 Ok(RulesResult::Injected) => injected.push(target.name.to_string()),
56 Ok(RulesResult::AlreadyPresent) => already.push(target.name.to_string()),
57 Err(e) => errors.push(format!("{}: {e}", target.name)),
58 }
59 }
60
61 (injected, already, errors)
62}
63
64enum RulesResult {
65 Injected,
66 AlreadyPresent,
67}
68
69fn inject_rules(target: &RulesTarget) -> Result<RulesResult, String> {
70 if target.path.exists() {
71 let content = std::fs::read_to_string(&target.path).map_err(|e| e.to_string())?;
72 if content.contains(MARKER) {
73 return Ok(RulesResult::AlreadyPresent);
74 }
75 }
76
77 if let Some(parent) = target.path.parent() {
78 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
79 }
80
81 match target.format {
82 RulesFormat::Markdown => append_markdown(&target.path),
83 RulesFormat::CursorMdc => write_cursor_mdc(&target.path),
84 }
85}
86
87fn append_markdown(path: &std::path::Path) -> Result<RulesResult, String> {
88 let mut content = if path.exists() {
89 std::fs::read_to_string(path).map_err(|e| e.to_string())?
90 } else {
91 String::new()
92 };
93
94 if !content.is_empty() && !content.ends_with('\n') {
95 content.push('\n');
96 }
97 if !content.is_empty() {
98 content.push('\n');
99 }
100 content.push_str(RULES_MD);
101 content.push('\n');
102
103 std::fs::write(path, content).map_err(|e| e.to_string())?;
104 Ok(RulesResult::Injected)
105}
106
107fn write_cursor_mdc(path: &std::path::Path) -> Result<RulesResult, String> {
108 if path.exists() {
109 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
110 if content.contains(MARKER) {
111 return Ok(RulesResult::AlreadyPresent);
112 }
113 }
114
115 std::fs::write(path, RULES_CURSORRULES).map_err(|e| e.to_string())?;
116 Ok(RulesResult::Injected)
117}
118
119fn is_tool_detected(target: &RulesTarget, home: &std::path::Path) -> bool {
120 match target.name {
121 "Claude Code" => {
122 if let Ok(output) = std::process::Command::new("which").arg("claude").output() {
123 if output.status.success() {
124 return true;
125 }
126 }
127 home.join(".claude.json").exists()
128 }
129 "Codex CLI" => {
130 home.join(".codex").exists() || {
131 std::process::Command::new("which")
132 .arg("codex")
133 .output()
134 .map(|o| o.status.success())
135 .unwrap_or(false)
136 }
137 }
138 "Cursor" => home.join(".cursor").exists(),
139 "Windsurf" => home.join(".codeium/windsurf").exists(),
140 "Gemini CLI" => home.join(".gemini").exists(),
141 "VS Code / Copilot" => detect_vscode_installed(),
142 "Zed" => home.join(".config/zed").exists(),
143 "Cline" => detect_cline_installed(),
144 "Roo Code" => detect_roo_installed(),
145 "OpenCode" => home.join(".config/opencode").exists(),
146 _ => false,
147 }
148}
149
150fn detect_vscode_installed() -> bool {
151 if let Some(home) = dirs::home_dir() {
152 #[cfg(target_os = "macos")]
153 if home
154 .join("Library/Application Support/Code/User/settings.json")
155 .exists()
156 {
157 return true;
158 }
159 #[cfg(target_os = "linux")]
160 if home.join(".config/Code/User/settings.json").exists() {
161 return true;
162 }
163 }
164 false
165}
166
167fn detect_cline_installed() -> bool {
168 if let Some(home) = dirs::home_dir() {
169 #[cfg(target_os = "macos")]
170 {
171 if home
172 .join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev")
173 .exists()
174 {
175 return true;
176 }
177 }
178 #[cfg(target_os = "linux")]
179 {
180 if home
181 .join(".config/Code/User/globalStorage/saoudrizwan.claude-dev")
182 .exists()
183 {
184 return true;
185 }
186 }
187 }
188 false
189}
190
191fn detect_roo_installed() -> bool {
192 if let Some(home) = dirs::home_dir() {
193 #[cfg(target_os = "macos")]
194 {
195 if home
196 .join("Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline")
197 .exists()
198 {
199 return true;
200 }
201 }
202 #[cfg(target_os = "linux")]
203 {
204 if home
205 .join(".config/Code/User/globalStorage/rooveterinaryinc.roo-cline")
206 .exists()
207 {
208 return true;
209 }
210 }
211 }
212 false
213}
214
215fn build_rules_targets(home: &std::path::Path) -> Vec<RulesTarget> {
216 vec![
217 RulesTarget {
218 name: "Claude Code",
219 path: home.join(".claude/CLAUDE.md"),
220 format: RulesFormat::Markdown,
221 },
222 RulesTarget {
223 name: "Codex CLI",
224 path: home.join(".codex/instructions.md"),
225 format: RulesFormat::Markdown,
226 },
227 RulesTarget {
228 name: "Cursor",
229 path: home.join(".cursor/rules/lean-ctx.mdc"),
230 format: RulesFormat::CursorMdc,
231 },
232 RulesTarget {
233 name: "Windsurf",
234 path: home.join(".codeium/windsurf/rules/lean-ctx.md"),
235 format: RulesFormat::Markdown,
236 },
237 RulesTarget {
238 name: "Gemini CLI",
239 path: home.join(".gemini/GEMINI.md"),
240 format: RulesFormat::Markdown,
241 },
242 RulesTarget {
243 name: "VS Code / Copilot",
244 path: copilot_instructions_path(home),
245 format: RulesFormat::Markdown,
246 },
247 RulesTarget {
248 name: "Zed",
249 path: home.join(".config/zed/rules/lean-ctx.md"),
250 format: RulesFormat::Markdown,
251 },
252 RulesTarget {
253 name: "Cline",
254 path: home.join(".cline/rules/lean-ctx.md"),
255 format: RulesFormat::Markdown,
256 },
257 RulesTarget {
258 name: "Roo Code",
259 path: home.join(".roo/rules/lean-ctx.md"),
260 format: RulesFormat::Markdown,
261 },
262 RulesTarget {
263 name: "OpenCode",
264 path: home.join(".config/opencode/rules/lean-ctx.md"),
265 format: RulesFormat::Markdown,
266 },
267 ]
268}
269
270fn copilot_instructions_path(home: &std::path::Path) -> PathBuf {
271 #[cfg(target_os = "macos")]
272 {
273 return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
274 }
275 #[cfg(target_os = "linux")]
276 {
277 return home.join(".config/Code/User/github-copilot-instructions.md");
278 }
279 #[cfg(target_os = "windows")]
280 {
281 if let Ok(appdata) = std::env::var("APPDATA") {
282 return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
283 }
284 }
285 #[allow(unreachable_code)]
286 home.join(".config/Code/User/github-copilot-instructions.md")
287}