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-v10";
8
9pub const RULES_MARKER: &str = MARKER;
10pub const RULES_VERSION_STR: &str = RULES_VERSION;
11
12pub fn rules_dedicated_markdown() -> &'static str {
13 RULES_DEDICATED
14}
15
16pub fn rules_shared_content() -> &'static str {
17 RULES_SHARED
18}
19
20const RULES_SHARED: &str = r"# lean-ctx — Context Engineering Layer
26<!-- lean-ctx-rules-v10 -->
27
28## Mode Selection
29- Editing the file? → `full` first, then `diff` for re-reads
30- Context only? → `map` or `signatures`
31- Large file? → `aggressive` or `entropy`
32- Specific lines? → `lines:N-M`
33- Unsure? → `auto`
34
35Anti-pattern: NEVER use `full` for files you won't edit — use `map` or `signatures`.
36
37## File Editing
38Use native Edit/Write/StrReplace — unchanged. lean-ctx replaces READ only.
39If Edit requires Read and Read is unavailable, use `ctx_edit(path, old_string, new_string)`.
40NEVER loop on Edit failures — switch to ctx_edit immediately.
41
42Fallback only if a lean-ctx tool is unavailable: use native equivalents.
43<!-- /lean-ctx -->";
44
45const RULES_DEDICATED: &str = r"# lean-ctx — Context Engineering Layer
51<!-- lean-ctx-rules-v10 -->
52
53## Mode Selection
541. Editing the file? → `full` first, then `diff` for re-reads
552. Need API surface only? → `map` or `signatures`
563. Large file, context only? → `entropy` or `aggressive`
574. Specific lines? → `lines:N-M`
585. Active task set? → `task`
596. Unsure? → `auto` (system selects optimal mode)
60
61Anti-pattern: NEVER use `full` for files you won't edit — use `map` or `signatures`.
62
63## File Editing
64Use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
65Write, Delete, Glob → use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
66
67## Proactive (use without being asked)
68- `ctx_overview(task)` at session start
69- `ctx_compress` when context grows large
70
71Fallback only if a lean-ctx tool is unavailable: use native equivalents.
72<!-- /lean-ctx -->";
73
74const RULES_CURSOR_MDC: &str = include_str!("templates/lean-ctx.mdc");
78
79struct RulesTarget {
82 name: &'static str,
83 path: PathBuf,
84 format: RulesFormat,
85}
86
87enum RulesFormat {
88 SharedMarkdown,
89 DedicatedMarkdown,
90 CursorMdc,
91}
92
93#[derive(Debug, Default)]
94pub struct InjectResult {
95 pub injected: Vec<String>,
96 pub updated: Vec<String>,
97 pub already: Vec<String>,
98 pub errors: Vec<String>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct RulesTargetStatus {
103 pub name: String,
104 pub detected: bool,
105 pub path: String,
106 pub state: String,
107 pub note: Option<String>,
108}
109
110pub fn inject_all_rules(home: &std::path::Path) -> InjectResult {
111 if crate::core::config::Config::load().rules_scope_effective()
112 == crate::core::config::RulesScope::Project
113 {
114 return InjectResult {
115 injected: Vec::new(),
116 updated: Vec::new(),
117 already: Vec::new(),
118 errors: Vec::new(),
119 };
120 }
121
122 let targets = build_rules_targets(home);
123
124 let mut result = InjectResult {
125 injected: Vec::new(),
126 updated: Vec::new(),
127 already: Vec::new(),
128 errors: Vec::new(),
129 };
130
131 for target in &targets {
132 if !is_tool_detected(target, home) {
133 continue;
134 }
135
136 match inject_rules(target) {
137 Ok(RulesResult::Injected) => result.injected.push(target.name.to_string()),
138 Ok(RulesResult::Updated) => result.updated.push(target.name.to_string()),
139 Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
140 Err(e) => result.errors.push(format!("{}: {e}", target.name)),
141 }
142 }
143
144 result
145}
146
147pub fn inject_rules_for_agent(home: &std::path::Path, agent_key: &str) -> InjectResult {
150 if crate::core::config::Config::load().rules_scope_effective()
151 == crate::core::config::RulesScope::Project
152 {
153 return InjectResult {
154 injected: Vec::new(),
155 updated: Vec::new(),
156 already: Vec::new(),
157 errors: Vec::new(),
158 };
159 }
160
161 let targets = build_rules_targets(home);
162 let mut result = InjectResult {
163 injected: Vec::new(),
164 updated: Vec::new(),
165 already: Vec::new(),
166 errors: Vec::new(),
167 };
168
169 for target in &targets {
170 if !match_agent_name(agent_key, target.name) {
171 continue;
172 }
173 match inject_rules(target) {
174 Ok(RulesResult::Injected) => result.injected.push(target.name.to_string()),
175 Ok(RulesResult::Updated) => result.updated.push(target.name.to_string()),
176 Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
177 Err(e) => result.errors.push(format!("{}: {e}", target.name)),
178 }
179 }
180
181 result
182}
183
184fn match_agent_name(cli_key: &str, target_name: &str) -> bool {
185 let needle = cli_key.to_lowercase();
186 let tn = target_name.to_lowercase();
187 needle.contains(&tn)
188 || tn.contains(&needle)
189 || (needle.contains("cursor") && tn.contains("cursor"))
190 || (needle.contains("claude") && tn.contains("claude"))
191 || (needle.contains("windsurf") && tn.contains("windsurf"))
192 || (needle.contains("codex") && tn.contains("claude"))
193 || (needle.contains("zed") && tn.contains("zed"))
194 || (needle.contains("copilot") && tn.contains("copilot"))
195 || (needle.contains("jetbrains") && tn.contains("jetbrains"))
196 || (needle.contains("kiro") && tn.contains("kiro"))
197 || (needle.contains("gemini") && tn.contains("gemini"))
198 || (needle == "opencode" && tn.contains("opencode"))
199 || (needle == "cline" && tn.contains("cline"))
200 || (needle == "roo" && tn.contains("roo"))
201 || (needle == "amp" && tn.contains("amp"))
202 || (needle == "trae" && tn.contains("trae"))
203 || (needle == "amazonq" && tn.contains("amazon"))
204 || (needle == "pi" && tn.contains("pi coding"))
205 || (needle == "crush" && tn.contains("crush"))
206 || (needle == "verdent" && tn.contains("verdent"))
207 || (needle == "continue" && tn.contains("continue"))
208 || (needle == "qwen" && tn.contains("qwen"))
209 || (needle == "antigravity" && tn.contains("antigravity"))
210 || (needle == "augment" && tn.contains("augment"))
211 || (needle == "openclaw" && tn.contains("openclaw"))
212 || (needle == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
213}
214
215pub fn check_rules_freshness(client_name: &str) -> Option<String> {
218 let home = dirs::home_dir()?;
219 let targets = build_rules_targets(&home);
220
221 let matched: Vec<&RulesTarget> = targets
222 .iter()
223 .filter(|t| match_agent_name(client_name, t.name))
224 .collect();
225
226 if matched.is_empty() {
227 return None;
228 }
229
230 for target in &matched {
231 if !target.path.exists() {
232 continue;
233 }
234 let content = std::fs::read_to_string(&target.path).ok()?;
235 if content.contains(MARKER) && !content.contains(RULES_VERSION) {
236 return Some(format!(
237 "[RULES OUTDATED] Your {} rules were written by an older lean-ctx version. \
238 Re-read your rules file ({}) or run `lean-ctx setup` to update, \
239 then start a new session for full compatibility.",
240 target.name,
241 target.path.display()
242 ));
243 }
244 }
245
246 None
247}
248
249pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
250 let targets = build_rules_targets(home);
251 let mut out = Vec::new();
252
253 for target in &targets {
254 let detected = is_tool_detected(target, home);
255 let path = target.path.to_string_lossy().to_string();
256
257 let state = if !detected {
258 "not_detected".to_string()
259 } else if !target.path.exists() {
260 "missing".to_string()
261 } else {
262 match std::fs::read_to_string(&target.path) {
263 Ok(content) => {
264 if content.contains(MARKER) {
265 if content.contains(RULES_VERSION) {
266 "up_to_date".to_string()
267 } else {
268 "outdated".to_string()
269 }
270 } else {
271 "present_without_marker".to_string()
272 }
273 }
274 Err(_) => "read_error".to_string(),
275 }
276 };
277
278 out.push(RulesTargetStatus {
279 name: target.name.to_string(),
280 detected,
281 path,
282 state,
283 note: None,
284 });
285 }
286
287 out
288}
289
290enum RulesResult {
295 Injected,
296 Updated,
297 AlreadyPresent,
298}
299
300fn rules_content(format: &RulesFormat) -> &'static str {
301 match format {
302 RulesFormat::SharedMarkdown => RULES_SHARED,
303 RulesFormat::DedicatedMarkdown => RULES_DEDICATED,
304 RulesFormat::CursorMdc => RULES_CURSOR_MDC,
305 }
306}
307
308fn inject_rules(target: &RulesTarget) -> Result<RulesResult, String> {
309 if target.path.exists() {
310 let content = std::fs::read_to_string(&target.path).map_err(|e| e.to_string())?;
311 if content.contains(MARKER) {
312 if content.contains(RULES_VERSION) {
313 return Ok(RulesResult::AlreadyPresent);
314 }
315 ensure_parent(&target.path)?;
316 return match target.format {
317 RulesFormat::SharedMarkdown => replace_markdown_section(&target.path, &content),
318 RulesFormat::DedicatedMarkdown | RulesFormat::CursorMdc => {
319 write_dedicated(&target.path, rules_content(&target.format))
320 }
321 };
322 }
323 }
324
325 ensure_parent(&target.path)?;
326
327 match target.format {
328 RulesFormat::SharedMarkdown => append_to_shared(&target.path),
329 RulesFormat::DedicatedMarkdown | RulesFormat::CursorMdc => {
330 write_dedicated(&target.path, rules_content(&target.format))
331 }
332 }
333}
334
335fn ensure_parent(path: &std::path::Path) -> Result<(), String> {
336 if let Some(parent) = path.parent() {
337 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
338 }
339 Ok(())
340}
341
342fn append_to_shared(path: &std::path::Path) -> Result<RulesResult, String> {
343 let mut content = if path.exists() {
344 std::fs::read_to_string(path).map_err(|e| e.to_string())?
345 } else {
346 String::new()
347 };
348
349 if !content.is_empty() && !content.ends_with('\n') {
350 content.push('\n');
351 }
352 if !content.is_empty() {
353 content.push('\n');
354 }
355 content.push_str(RULES_SHARED);
356 content.push('\n');
357
358 crate::config_io::write_atomic_with_backup(path, &content)?;
359 Ok(RulesResult::Injected)
360}
361
362fn replace_markdown_section(path: &std::path::Path, content: &str) -> Result<RulesResult, String> {
363 let start = content.find(MARKER);
364 let end = content.find(END_MARKER);
365
366 let new_content = match (start, end) {
367 (Some(s), Some(e)) => {
368 let before = &content[..s];
369 let after_end = e + END_MARKER.len();
370 let after = content[after_end..].trim_start_matches('\n');
371 let mut result = before.to_string();
372 result.push_str(RULES_SHARED);
373 if !after.is_empty() {
374 result.push('\n');
375 result.push_str(after);
376 }
377 result
378 }
379 (Some(s), None) => {
380 let before = &content[..s];
381 let mut result = before.to_string();
382 result.push_str(RULES_SHARED);
383 result.push('\n');
384 result
385 }
386 _ => return Ok(RulesResult::AlreadyPresent),
387 };
388
389 crate::config_io::write_atomic_with_backup(path, &new_content)?;
390 Ok(RulesResult::Updated)
391}
392
393fn write_dedicated(path: &std::path::Path, content: &'static str) -> Result<RulesResult, String> {
394 let is_update = path.exists() && {
395 let existing = std::fs::read_to_string(path).unwrap_or_default();
396 existing.contains(MARKER)
397 };
398
399 crate::config_io::write_atomic_with_backup(path, content)?;
400
401 if is_update {
402 Ok(RulesResult::Updated)
403 } else {
404 Ok(RulesResult::Injected)
405 }
406}
407
408fn is_tool_detected(target: &RulesTarget, home: &std::path::Path) -> bool {
413 match target.name {
414 "Claude Code" => {
415 if command_exists("claude") {
416 return true;
417 }
418 let state_dir = crate::core::editor_registry::claude_state_dir(home);
419 crate::core::editor_registry::claude_mcp_json_path(home).exists() || state_dir.exists()
420 }
421 "Codex CLI" => {
422 let codex_dir =
423 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
424 codex_dir.exists() || command_exists("codex")
425 }
426 "Cursor" => home.join(".cursor").exists(),
427 "Windsurf" => home.join(".codeium/windsurf").exists(),
428 "Gemini CLI" => home.join(".gemini").exists(),
429 "VS Code" => detect_vscode_installed(home),
430 "Copilot CLI" => home.join(".copilot").exists() || command_exists("copilot"),
431 "Zed" => home.join(".config/zed").exists(),
432 "Cline" => detect_extension_installed(home, "saoudrizwan.claude-dev"),
433 "Roo Code" => detect_extension_installed(home, "rooveterinaryinc.roo-cline"),
434 "OpenCode" => home.join(".config/opencode").exists(),
435 "Continue" => detect_extension_installed(home, "continue.continue"),
436 "Amp" => command_exists("amp") || home.join(".ampcoder").exists(),
437 "Qwen Code" => home.join(".qwen").exists(),
438 "Trae" => home.join(".trae").exists(),
439 "Amazon Q Developer" => home.join(".aws/amazonq").exists(),
440 "JetBrains IDEs" => detect_jetbrains_installed(home),
441 "Antigravity" => home.join(".gemini/antigravity").exists(),
442 "Pi Coding Agent" => home.join(".pi").exists() || command_exists("pi"),
443 "AWS Kiro" => home.join(".kiro").exists(),
444 "Crush" => home.join(".config/crush").exists() || command_exists("crush"),
445 "Verdent" => home.join(".verdent").exists(),
446 "Augment" => {
449 command_exists("auggie")
450 || home.join(".augment").exists()
451 || detect_extension_installed(home, "augment.vscode-augment")
452 }
453 _ => false,
454 }
455}
456
457fn command_exists(name: &str) -> bool {
458 #[cfg(target_os = "windows")]
459 let result = std::process::Command::new("where")
460 .arg(name)
461 .output()
462 .is_ok_and(|o| o.status.success());
463
464 #[cfg(not(target_os = "windows"))]
465 let result = std::process::Command::new("which")
466 .arg(name)
467 .output()
468 .is_ok_and(|o| o.status.success());
469
470 result
471}
472
473fn detect_vscode_installed(_home: &std::path::Path) -> bool {
474 let check_dir = |dir: PathBuf| -> bool {
475 dir.join("settings.json").exists() || dir.join("mcp.json").exists()
476 };
477
478 #[cfg(target_os = "macos")]
479 if check_dir(_home.join("Library/Application Support/Code/User")) {
480 return true;
481 }
482 #[cfg(target_os = "linux")]
483 if check_dir(_home.join(".config/Code/User")) {
484 return true;
485 }
486 #[cfg(target_os = "windows")]
487 if let Ok(appdata) = std::env::var("APPDATA") {
488 if check_dir(PathBuf::from(&appdata).join("Code/User")) {
489 return true;
490 }
491 }
492 false
493}
494
495fn detect_jetbrains_installed(home: &std::path::Path) -> bool {
496 #[cfg(target_os = "macos")]
497 if home.join("Library/Application Support/JetBrains").exists() {
498 return true;
499 }
500 #[cfg(target_os = "linux")]
501 if home.join(".config/JetBrains").exists() {
502 return true;
503 }
504 home.join(".jb-mcp.json").exists()
505}
506
507fn detect_extension_installed(_home: &std::path::Path, extension_id: &str) -> bool {
508 #[cfg(target_os = "macos")]
509 {
510 if _home
511 .join(format!(
512 "Library/Application Support/Code/User/globalStorage/{extension_id}"
513 ))
514 .exists()
515 {
516 return true;
517 }
518 }
519 #[cfg(target_os = "linux")]
520 {
521 if _home
522 .join(format!(".config/Code/User/globalStorage/{extension_id}"))
523 .exists()
524 {
525 return true;
526 }
527 }
528 #[cfg(target_os = "windows")]
529 {
530 if let Ok(appdata) = std::env::var("APPDATA") {
531 if std::path::PathBuf::from(&appdata)
532 .join(format!("Code/User/globalStorage/{extension_id}"))
533 .exists()
534 {
535 return true;
536 }
537 }
538 }
539 false
540}
541
542fn build_rules_targets(home: &std::path::Path) -> Vec<RulesTarget> {
547 vec![
548 RulesTarget {
550 name: "Claude Code",
551 path: crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
552 format: RulesFormat::DedicatedMarkdown,
553 },
554 RulesTarget {
555 name: "Gemini CLI",
556 path: home.join(".gemini/GEMINI.md"),
557 format: RulesFormat::SharedMarkdown,
558 },
559 RulesTarget {
560 name: "VS Code",
561 path: copilot_instructions_path(home),
562 format: RulesFormat::SharedMarkdown,
563 },
564 RulesTarget {
565 name: "Copilot CLI",
566 path: home.join(".copilot/instructions.md"),
567 format: RulesFormat::SharedMarkdown,
568 },
569 RulesTarget {
571 name: "Cursor",
572 path: home.join(".cursor/rules/lean-ctx.mdc"),
573 format: RulesFormat::CursorMdc,
574 },
575 RulesTarget {
576 name: "Windsurf",
577 path: home.join(".codeium/windsurf/rules/lean-ctx.md"),
578 format: RulesFormat::DedicatedMarkdown,
579 },
580 RulesTarget {
581 name: "Zed",
582 path: home.join(".config/zed/rules/lean-ctx.md"),
583 format: RulesFormat::DedicatedMarkdown,
584 },
585 RulesTarget {
586 name: "Cline",
587 path: home.join(".cline/rules/lean-ctx.md"),
588 format: RulesFormat::DedicatedMarkdown,
589 },
590 RulesTarget {
591 name: "Roo Code",
592 path: home.join(".roo/rules/lean-ctx.md"),
593 format: RulesFormat::DedicatedMarkdown,
594 },
595 RulesTarget {
596 name: "OpenCode",
597 path: home.join(".config/opencode/AGENTS.md"),
598 format: RulesFormat::SharedMarkdown,
599 },
600 RulesTarget {
601 name: "Continue",
602 path: home.join(".continue/rules/lean-ctx.md"),
603 format: RulesFormat::DedicatedMarkdown,
604 },
605 RulesTarget {
606 name: "Amp",
607 path: home.join(".ampcoder/rules/lean-ctx.md"),
608 format: RulesFormat::DedicatedMarkdown,
609 },
610 RulesTarget {
611 name: "Qwen Code",
612 path: home.join(".qwen/rules/lean-ctx.md"),
613 format: RulesFormat::DedicatedMarkdown,
614 },
615 RulesTarget {
616 name: "Trae",
617 path: home.join(".trae/rules/lean-ctx.md"),
618 format: RulesFormat::DedicatedMarkdown,
619 },
620 RulesTarget {
621 name: "Amazon Q Developer",
622 path: home.join(".aws/amazonq/rules/lean-ctx.md"),
623 format: RulesFormat::DedicatedMarkdown,
624 },
625 RulesTarget {
626 name: "JetBrains IDEs",
627 path: home.join(".jb-rules/lean-ctx.md"),
628 format: RulesFormat::DedicatedMarkdown,
629 },
630 RulesTarget {
631 name: "Antigravity",
632 path: home.join(".gemini/antigravity/rules/lean-ctx.md"),
633 format: RulesFormat::DedicatedMarkdown,
634 },
635 RulesTarget {
636 name: "Pi Coding Agent",
637 path: home.join(".pi/rules/lean-ctx.md"),
638 format: RulesFormat::DedicatedMarkdown,
639 },
640 RulesTarget {
641 name: "AWS Kiro",
642 path: home.join(".kiro/steering/lean-ctx.md"),
643 format: RulesFormat::DedicatedMarkdown,
644 },
645 RulesTarget {
646 name: "Verdent",
647 path: home.join(".verdent/rules/lean-ctx.md"),
648 format: RulesFormat::DedicatedMarkdown,
649 },
650 RulesTarget {
651 name: "Crush",
652 path: home.join(".config/crush/rules/lean-ctx.md"),
653 format: RulesFormat::DedicatedMarkdown,
654 },
655 RulesTarget {
656 name: "Augment",
657 path: home.join(".augment/rules/lean-ctx.md"),
658 format: RulesFormat::DedicatedMarkdown,
659 },
660 RulesTarget {
661 name: "OpenClaw",
662 path: home.join(".openclaw/rules/lean-ctx.md"),
663 format: RulesFormat::DedicatedMarkdown,
664 },
665 ]
666}
667
668fn copilot_instructions_path(home: &std::path::Path) -> PathBuf {
669 #[cfg(target_os = "macos")]
670 {
671 return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
672 }
673 #[cfg(target_os = "linux")]
674 {
675 return home.join(".config/Code/User/github-copilot-instructions.md");
676 }
677 #[cfg(target_os = "windows")]
678 {
679 if let Ok(appdata) = std::env::var("APPDATA") {
680 return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
681 }
682 }
683 #[allow(unreachable_code)]
684 home.join(".config/Code/User/github-copilot-instructions.md")
685}
686
687const SKILL_TEMPLATE: &str = include_str!("templates/SKILL.md");
692
693struct SkillTarget {
694 agent_key: &'static str,
695 display_name: &'static str,
696 skill_dir: PathBuf,
697}
698
699fn build_skill_targets(home: &std::path::Path) -> Vec<SkillTarget> {
700 vec![
701 SkillTarget {
702 agent_key: "claude",
703 display_name: "Claude Code",
704 skill_dir: crate::setup::claude_config_dir(home).join("skills/lean-ctx"),
705 },
706 SkillTarget {
707 agent_key: "cursor",
708 display_name: "Cursor",
709 skill_dir: home.join(".cursor/skills/lean-ctx"),
710 },
711 SkillTarget {
712 agent_key: "codex",
713 display_name: "Codex CLI",
714 skill_dir: crate::core::home::resolve_codex_dir()
715 .unwrap_or_else(|| home.join(".codex"))
716 .join("skills/lean-ctx"),
717 },
718 SkillTarget {
719 agent_key: "copilot",
720 display_name: "GitHub Copilot",
721 skill_dir: home.join(".copilot/skills/lean-ctx"),
722 },
723 SkillTarget {
724 agent_key: "openclaw",
725 display_name: "OpenClaw",
726 skill_dir: home.join(".openclaw/skills/lean-ctx"),
727 },
728 ]
729}
730
731fn is_skill_agent_detected(agent_key: &str, home: &std::path::Path) -> bool {
732 match agent_key {
733 "claude" => {
734 command_exists("claude")
735 || crate::core::editor_registry::claude_mcp_json_path(home).exists()
736 || crate::core::editor_registry::claude_state_dir(home).exists()
737 }
738 "cursor" => home.join(".cursor").exists(),
739 "codex" => {
740 let codex_dir =
741 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
742 codex_dir.exists() || command_exists("codex")
743 }
744 "copilot" => {
745 home.join(".copilot").exists()
746 || home.join(".copilot/mcp-config.json").exists()
747 || command_exists("copilot")
748 }
749 "openclaw" => home.join(".openclaw").exists() || command_exists("openclaw"),
750 _ => false,
751 }
752}
753
754pub fn install_skill_for_agent(home: &std::path::Path, agent_key: &str) -> Result<PathBuf, String> {
756 let targets = build_skill_targets(home);
757 let target = targets
758 .into_iter()
759 .find(|t| t.agent_key == agent_key)
760 .ok_or_else(|| format!("No skill target for agent '{agent_key}'"))?;
761
762 let skill_path = target.skill_dir.join("SKILL.md");
763 std::fs::create_dir_all(&target.skill_dir).map_err(|e| e.to_string())?;
764
765 if skill_path.exists() {
766 let existing = std::fs::read_to_string(&skill_path).unwrap_or_default();
767 if existing == SKILL_TEMPLATE {
768 return Ok(skill_path);
769 }
770 }
771
772 crate::config_io::write_atomic_with_backup(&skill_path, SKILL_TEMPLATE)?;
773 Ok(skill_path)
774}
775
776pub fn install_all_skills(home: &std::path::Path) -> Vec<(String, bool)> {
779 let targets = build_skill_targets(home);
780 let mut results = Vec::new();
781
782 for target in &targets {
783 if !is_skill_agent_detected(target.agent_key, home) {
784 continue;
785 }
786
787 let skill_path = target.skill_dir.join("SKILL.md");
788 let already_current = skill_path.exists()
789 && std::fs::read_to_string(&skill_path).is_ok_and(|c| c == SKILL_TEMPLATE);
790
791 if already_current {
792 results.push((target.display_name.to_string(), false));
793 continue;
794 }
795
796 if let Err(e) = std::fs::create_dir_all(&target.skill_dir) {
797 tracing::warn!(
798 "Failed to create skill dir for {}: {e}",
799 target.display_name
800 );
801 continue;
802 }
803
804 match crate::config_io::write_atomic_with_backup(&skill_path, SKILL_TEMPLATE) {
805 Ok(()) => results.push((target.display_name.to_string(), true)),
806 Err(e) => {
807 tracing::warn!("Failed to write SKILL.md for {}: {e}", target.display_name);
808 }
809 }
810 }
811
812 results
813}
814
815#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 fn shared_rules_have_markers() {
825 assert!(RULES_SHARED.contains(MARKER));
826 assert!(RULES_SHARED.contains(END_MARKER));
827 assert!(RULES_SHARED.contains(RULES_VERSION));
828 }
829
830 #[test]
831 fn dedicated_rules_have_markers() {
832 assert!(RULES_DEDICATED.contains(MARKER));
833 assert!(RULES_DEDICATED.contains(END_MARKER));
834 assert!(RULES_DEDICATED.contains(RULES_VERSION));
835 }
836
837 #[test]
838 fn cursor_mdc_has_markers_and_frontmatter() {
839 assert!(RULES_CURSOR_MDC.contains("lean-ctx"));
840 assert!(RULES_CURSOR_MDC.contains(END_MARKER));
841 assert!(RULES_CURSOR_MDC.contains(RULES_VERSION));
842 assert!(RULES_CURSOR_MDC.contains("alwaysApply: true"));
843 }
844
845 #[test]
846 fn shared_rules_contain_mode_selection() {
847 assert!(RULES_SHARED.contains("Mode Selection"));
848 assert!(RULES_SHARED.contains("full"));
849 assert!(RULES_SHARED.contains("map"));
850 assert!(RULES_SHARED.contains("signatures"));
851 assert!(RULES_SHARED.contains("NEVER"));
852 }
853
854 #[test]
855 fn shared_rules_has_anti_pattern() {
856 assert!(RULES_SHARED.contains("Anti-pattern"));
857 assert!(RULES_SHARED.contains("NEVER use `full`"));
858 }
859
860 #[test]
861 fn dedicated_rules_contain_modes() {
862 assert!(RULES_DEDICATED.contains("auto"));
863 assert!(RULES_DEDICATED.contains("full"));
864 assert!(RULES_DEDICATED.contains("map"));
865 assert!(RULES_DEDICATED.contains("signatures"));
866 assert!(RULES_DEDICATED.contains("entropy"));
867 assert!(RULES_DEDICATED.contains("aggressive"));
868 assert!(RULES_DEDICATED.contains("task"));
869 assert!(RULES_DEDICATED.contains("lines:N-M"));
870 }
871
872 #[test]
873 fn dedicated_rules_has_proactive_section() {
874 assert!(RULES_DEDICATED.contains("Proactive"));
875 assert!(RULES_DEDICATED.contains("ctx_overview"));
876 assert!(RULES_DEDICATED.contains("ctx_compress"));
877 }
878
879 #[test]
880 fn cursor_mdc_contains_mode_selection() {
881 assert!(RULES_CURSOR_MDC.contains("Mode Selection"));
882 assert!(RULES_CURSOR_MDC.contains("ctx_read"));
883 assert!(RULES_CURSOR_MDC.contains("ctx_search"));
884 assert!(RULES_CURSOR_MDC.contains("lean-ctx -c"));
885 }
886
887 fn ensure_temp_dir() {
888 let tmp = std::env::temp_dir();
889 if !tmp.exists() {
890 std::fs::create_dir_all(&tmp).ok();
891 }
892 }
893
894 #[test]
895 fn replace_section_with_end_marker() {
896 ensure_temp_dir();
897 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";
898 let path = std::env::temp_dir().join("test_replace_with_end.md");
899 std::fs::write(&path, old).unwrap();
900
901 let result = replace_markdown_section(&path, old).unwrap();
902 assert!(matches!(result, RulesResult::Updated));
903
904 let new_content = std::fs::read_to_string(&path).unwrap();
905 assert!(new_content.contains(RULES_VERSION));
906 assert!(new_content.starts_with("user stuff"));
907 assert!(new_content.contains("more user stuff"));
908 assert!(!new_content.contains("lean-ctx-rules-v2"));
909
910 std::fs::remove_file(&path).ok();
911 }
912
913 #[test]
914 fn replace_section_without_end_marker() {
915 ensure_temp_dir();
916 let old = "user stuff\n\n# lean-ctx — Context Engineering Layer\nold rules only\n";
917 let path = std::env::temp_dir().join("test_replace_no_end.md");
918 std::fs::write(&path, old).unwrap();
919
920 let result = replace_markdown_section(&path, old).unwrap();
921 assert!(matches!(result, RulesResult::Updated));
922
923 let new_content = std::fs::read_to_string(&path).unwrap();
924 assert!(new_content.contains(RULES_VERSION));
925 assert!(new_content.starts_with("user stuff"));
926
927 std::fs::remove_file(&path).ok();
928 }
929
930 #[test]
931 fn append_to_shared_preserves_existing() {
932 ensure_temp_dir();
933 let path = std::env::temp_dir().join("test_append_shared.md");
934 std::fs::write(&path, "existing user rules\n").unwrap();
935
936 let result = append_to_shared(&path).unwrap();
937 assert!(matches!(result, RulesResult::Injected));
938
939 let content = std::fs::read_to_string(&path).unwrap();
940 assert!(content.starts_with("existing user rules"));
941 assert!(content.contains(MARKER));
942 assert!(content.contains(END_MARKER));
943
944 std::fs::remove_file(&path).ok();
945 }
946
947 #[test]
948 fn write_dedicated_creates_file() {
949 ensure_temp_dir();
950 let path = std::env::temp_dir().join("test_write_dedicated.md");
951 if path.exists() {
952 std::fs::remove_file(&path).ok();
953 }
954
955 let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
956 assert!(matches!(result, RulesResult::Injected));
957
958 let content = std::fs::read_to_string(&path).unwrap();
959 assert!(content.contains(MARKER));
960 assert!(content.contains("Mode Selection"));
961
962 std::fs::remove_file(&path).ok();
963 }
964
965 #[test]
966 fn write_dedicated_updates_existing() {
967 ensure_temp_dir();
968 let path = std::env::temp_dir().join("test_write_dedicated_update.md");
969 std::fs::write(&path, "# lean-ctx — Context Engineering Layer\nold version").unwrap();
970
971 let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
972 assert!(matches!(result, RulesResult::Updated));
973
974 std::fs::remove_file(&path).ok();
975 }
976
977 #[test]
978 fn target_count() {
979 let home = std::path::PathBuf::from("/tmp/fake_home");
980 let targets = build_rules_targets(&home);
981 assert_eq!(targets.len(), 23);
982 }
983
984 #[test]
985 fn skill_template_not_empty() {
986 assert!(!SKILL_TEMPLATE.is_empty());
987 assert!(SKILL_TEMPLATE.contains("lean-ctx"));
988 }
989
990 #[test]
991 fn skill_targets_count() {
992 let home = std::path::PathBuf::from("/tmp/fake_home");
993 let targets = build_skill_targets(&home);
994 assert_eq!(targets.len(), 5);
995 }
996
997 #[test]
998 fn install_skill_creates_file() {
999 ensure_temp_dir();
1000 let home = std::env::temp_dir().join("test_skill_install");
1001 let _ = std::fs::create_dir_all(&home);
1002
1003 let fake_cursor = home.join(".cursor");
1004 let _ = std::fs::create_dir_all(&fake_cursor);
1005
1006 let result = install_skill_for_agent(&home, "cursor");
1007 assert!(result.is_ok());
1008
1009 let path = result.unwrap();
1010 assert!(path.exists());
1011 let content = std::fs::read_to_string(&path).unwrap();
1012 assert_eq!(content, SKILL_TEMPLATE);
1013
1014 let _ = std::fs::remove_dir_all(&home);
1015 }
1016
1017 #[test]
1018 fn install_skill_idempotent() {
1019 ensure_temp_dir();
1020 let home = std::env::temp_dir().join("test_skill_idempotent");
1021 let _ = std::fs::create_dir_all(&home);
1022
1023 let fake_cursor = home.join(".cursor");
1024 let _ = std::fs::create_dir_all(&fake_cursor);
1025
1026 let p1 = install_skill_for_agent(&home, "cursor").unwrap();
1027 let p2 = install_skill_for_agent(&home, "cursor").unwrap();
1028 assert_eq!(p1, p2);
1029
1030 let _ = std::fs::remove_dir_all(&home);
1031 }
1032
1033 #[test]
1034 fn install_skill_unknown_agent() {
1035 let home = std::path::PathBuf::from("/tmp/fake_home");
1036 let result = install_skill_for_agent(&home, "unknown_agent");
1037 assert!(result.is_err());
1038 }
1039
1040 #[test]
1041 fn match_agent_name_basic() {
1042 assert!(match_agent_name("cursor", "Cursor"));
1043 assert!(match_agent_name("opencode", "OpenCode"));
1044 assert!(match_agent_name("claude", "Claude Code"));
1045 assert!(match_agent_name("vscode", "VS Code"));
1046 assert!(match_agent_name("copilot", "Copilot CLI"));
1047 assert!(match_agent_name("kiro", "AWS Kiro"));
1048 assert!(match_agent_name("pi", "Pi Coding Agent"));
1049 assert!(match_agent_name("crush", "Crush"));
1050 assert!(match_agent_name("amp", "Amp"));
1051 assert!(match_agent_name("cline", "Cline"));
1052 assert!(match_agent_name("roo", "Roo Code"));
1053 assert!(match_agent_name("trae", "Trae"));
1054 assert!(match_agent_name("amazonq", "Amazon Q Developer"));
1055 assert!(match_agent_name("verdent", "Verdent"));
1056 assert!(match_agent_name("continue", "Continue"));
1057 assert!(match_agent_name("antigravity", "Antigravity"));
1058 assert!(match_agent_name("gemini", "Gemini CLI"));
1059 assert!(match_agent_name("augment", "Augment"));
1060 assert!(match_agent_name("openclaw", "OpenClaw"));
1061 }
1062
1063 #[test]
1064 fn match_agent_name_no_false_positives() {
1065 assert!(!match_agent_name("cursor", "Claude Code"));
1066 assert!(!match_agent_name("opencode", "Cursor"));
1067 assert!(!match_agent_name("unknown_agent", "Cursor"));
1068 }
1069
1070 #[test]
1071 fn inject_rules_for_agent_opencode() {
1072 ensure_temp_dir();
1073 let home = std::env::temp_dir().join("test_inject_rules_agent");
1074 let _ = std::fs::remove_dir_all(&home);
1075 let _ = std::fs::create_dir_all(&home);
1076
1077 let opencode_dir = home.join(".config/opencode");
1078 let _ = std::fs::create_dir_all(&opencode_dir);
1079
1080 let result = inject_rules_for_agent(&home, "opencode");
1081 assert!(
1082 !result.injected.is_empty() || !result.already.is_empty(),
1083 "should inject or find rules for OpenCode"
1084 );
1085 assert!(result.errors.is_empty(), "no errors expected");
1086
1087 let agents_md = opencode_dir.join("AGENTS.md");
1088 if agents_md.exists() {
1089 let content = std::fs::read_to_string(&agents_md).unwrap();
1090 assert!(content.contains(RULES_VERSION));
1091 }
1092
1093 let _ = std::fs::remove_dir_all(&home);
1094 }
1095
1096 #[test]
1097 fn inject_rules_for_agent_cursor() {
1098 ensure_temp_dir();
1099 let home = std::env::temp_dir().join("test_inject_rules_cursor");
1100 let _ = std::fs::remove_dir_all(&home);
1101 let _ = std::fs::create_dir_all(&home);
1102
1103 let cursor_dir = home.join(".cursor");
1104 let _ = std::fs::create_dir_all(&cursor_dir);
1105
1106 let result = inject_rules_for_agent(&home, "cursor");
1107 assert!(result.errors.is_empty(), "no errors expected");
1108
1109 let mdc_path = home.join(".cursor/rules/lean-ctx.mdc");
1110 if mdc_path.exists() {
1111 let content = std::fs::read_to_string(&mdc_path).unwrap();
1112 assert!(content.contains(RULES_VERSION));
1113 }
1114
1115 let _ = std::fs::remove_dir_all(&home);
1116 }
1117
1118 #[test]
1119 fn inject_rules_for_unknown_agent_is_empty() {
1120 let home = std::path::PathBuf::from("/tmp/fake_home_unknown");
1121 let result = inject_rules_for_agent(&home, "unknown_agent_xyz");
1122 assert!(result.injected.is_empty());
1123 assert!(result.updated.is_empty());
1124 assert!(result.already.is_empty());
1125 assert!(result.errors.is_empty());
1126 }
1127}