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 == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
212}
213
214pub fn check_rules_freshness(client_name: &str) -> Option<String> {
217 let home = dirs::home_dir()?;
218 let targets = build_rules_targets(&home);
219
220 let matched: Vec<&RulesTarget> = targets
221 .iter()
222 .filter(|t| match_agent_name(client_name, t.name))
223 .collect();
224
225 if matched.is_empty() {
226 return None;
227 }
228
229 for target in &matched {
230 if !target.path.exists() {
231 continue;
232 }
233 let content = std::fs::read_to_string(&target.path).ok()?;
234 if content.contains(MARKER) && !content.contains(RULES_VERSION) {
235 return Some(format!(
236 "[RULES OUTDATED] Your {} rules were written by an older lean-ctx version. \
237 Re-read your rules file ({}) or run `lean-ctx setup` to update, \
238 then start a new session for full compatibility.",
239 target.name,
240 target.path.display()
241 ));
242 }
243 }
244
245 None
246}
247
248pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
249 let targets = build_rules_targets(home);
250 let mut out = Vec::new();
251
252 for target in &targets {
253 let detected = is_tool_detected(target, home);
254 let path = target.path.to_string_lossy().to_string();
255
256 let state = if !detected {
257 "not_detected".to_string()
258 } else if !target.path.exists() {
259 "missing".to_string()
260 } else {
261 match std::fs::read_to_string(&target.path) {
262 Ok(content) => {
263 if content.contains(MARKER) {
264 if content.contains(RULES_VERSION) {
265 "up_to_date".to_string()
266 } else {
267 "outdated".to_string()
268 }
269 } else {
270 "present_without_marker".to_string()
271 }
272 }
273 Err(_) => "read_error".to_string(),
274 }
275 };
276
277 out.push(RulesTargetStatus {
278 name: target.name.to_string(),
279 detected,
280 path,
281 state,
282 note: None,
283 });
284 }
285
286 out
287}
288
289enum RulesResult {
294 Injected,
295 Updated,
296 AlreadyPresent,
297}
298
299fn rules_content(format: &RulesFormat) -> &'static str {
300 match format {
301 RulesFormat::SharedMarkdown => RULES_SHARED,
302 RulesFormat::DedicatedMarkdown => RULES_DEDICATED,
303 RulesFormat::CursorMdc => RULES_CURSOR_MDC,
304 }
305}
306
307fn inject_rules(target: &RulesTarget) -> Result<RulesResult, String> {
308 if target.path.exists() {
309 let content = std::fs::read_to_string(&target.path).map_err(|e| e.to_string())?;
310 if content.contains(MARKER) {
311 if content.contains(RULES_VERSION) {
312 return Ok(RulesResult::AlreadyPresent);
313 }
314 ensure_parent(&target.path)?;
315 return match target.format {
316 RulesFormat::SharedMarkdown => replace_markdown_section(&target.path, &content),
317 RulesFormat::DedicatedMarkdown | RulesFormat::CursorMdc => {
318 write_dedicated(&target.path, rules_content(&target.format))
319 }
320 };
321 }
322 }
323
324 ensure_parent(&target.path)?;
325
326 match target.format {
327 RulesFormat::SharedMarkdown => append_to_shared(&target.path),
328 RulesFormat::DedicatedMarkdown | RulesFormat::CursorMdc => {
329 write_dedicated(&target.path, rules_content(&target.format))
330 }
331 }
332}
333
334fn ensure_parent(path: &std::path::Path) -> Result<(), String> {
335 if let Some(parent) = path.parent() {
336 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
337 }
338 Ok(())
339}
340
341fn append_to_shared(path: &std::path::Path) -> Result<RulesResult, String> {
342 let mut content = if path.exists() {
343 std::fs::read_to_string(path).map_err(|e| e.to_string())?
344 } else {
345 String::new()
346 };
347
348 if !content.is_empty() && !content.ends_with('\n') {
349 content.push('\n');
350 }
351 if !content.is_empty() {
352 content.push('\n');
353 }
354 content.push_str(RULES_SHARED);
355 content.push('\n');
356
357 crate::config_io::write_atomic_with_backup(path, &content)?;
358 Ok(RulesResult::Injected)
359}
360
361fn replace_markdown_section(path: &std::path::Path, content: &str) -> Result<RulesResult, String> {
362 let start = content.find(MARKER);
363 let end = content.find(END_MARKER);
364
365 let new_content = match (start, end) {
366 (Some(s), Some(e)) => {
367 let before = &content[..s];
368 let after_end = e + END_MARKER.len();
369 let after = content[after_end..].trim_start_matches('\n');
370 let mut result = before.to_string();
371 result.push_str(RULES_SHARED);
372 if !after.is_empty() {
373 result.push('\n');
374 result.push_str(after);
375 }
376 result
377 }
378 (Some(s), None) => {
379 let before = &content[..s];
380 let mut result = before.to_string();
381 result.push_str(RULES_SHARED);
382 result.push('\n');
383 result
384 }
385 _ => return Ok(RulesResult::AlreadyPresent),
386 };
387
388 crate::config_io::write_atomic_with_backup(path, &new_content)?;
389 Ok(RulesResult::Updated)
390}
391
392fn write_dedicated(path: &std::path::Path, content: &'static str) -> Result<RulesResult, String> {
393 let is_update = path.exists() && {
394 let existing = std::fs::read_to_string(path).unwrap_or_default();
395 existing.contains(MARKER)
396 };
397
398 crate::config_io::write_atomic_with_backup(path, content)?;
399
400 if is_update {
401 Ok(RulesResult::Updated)
402 } else {
403 Ok(RulesResult::Injected)
404 }
405}
406
407fn is_tool_detected(target: &RulesTarget, home: &std::path::Path) -> bool {
412 match target.name {
413 "Claude Code" => {
414 if command_exists("claude") {
415 return true;
416 }
417 let state_dir = crate::core::editor_registry::claude_state_dir(home);
418 crate::core::editor_registry::claude_mcp_json_path(home).exists() || state_dir.exists()
419 }
420 "Codex CLI" => {
421 let codex_dir =
422 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
423 codex_dir.exists() || command_exists("codex")
424 }
425 "Cursor" => home.join(".cursor").exists(),
426 "Windsurf" => home.join(".codeium/windsurf").exists(),
427 "Gemini CLI" => home.join(".gemini").exists(),
428 "VS Code" => detect_vscode_installed(home),
429 "Copilot CLI" => home.join(".copilot").exists() || command_exists("copilot"),
430 "Zed" => home.join(".config/zed").exists(),
431 "Cline" => detect_extension_installed(home, "saoudrizwan.claude-dev"),
432 "Roo Code" => detect_extension_installed(home, "rooveterinaryinc.roo-cline"),
433 "OpenCode" => home.join(".config/opencode").exists(),
434 "Continue" => detect_extension_installed(home, "continue.continue"),
435 "Amp" => command_exists("amp") || home.join(".ampcoder").exists(),
436 "Qwen Code" => home.join(".qwen").exists(),
437 "Trae" => home.join(".trae").exists(),
438 "Amazon Q Developer" => home.join(".aws/amazonq").exists(),
439 "JetBrains IDEs" => detect_jetbrains_installed(home),
440 "Antigravity" => home.join(".gemini/antigravity").exists(),
441 "Pi Coding Agent" => home.join(".pi").exists() || command_exists("pi"),
442 "AWS Kiro" => home.join(".kiro").exists(),
443 "Crush" => home.join(".config/crush").exists() || command_exists("crush"),
444 "Verdent" => home.join(".verdent").exists(),
445 "Augment" => {
448 command_exists("auggie")
449 || home.join(".augment").exists()
450 || detect_extension_installed(home, "augment.vscode-augment")
451 }
452 _ => false,
453 }
454}
455
456fn command_exists(name: &str) -> bool {
457 #[cfg(target_os = "windows")]
458 let result = std::process::Command::new("where")
459 .arg(name)
460 .output()
461 .is_ok_and(|o| o.status.success());
462
463 #[cfg(not(target_os = "windows"))]
464 let result = std::process::Command::new("which")
465 .arg(name)
466 .output()
467 .is_ok_and(|o| o.status.success());
468
469 result
470}
471
472fn detect_vscode_installed(_home: &std::path::Path) -> bool {
473 let check_dir = |dir: PathBuf| -> bool {
474 dir.join("settings.json").exists() || dir.join("mcp.json").exists()
475 };
476
477 #[cfg(target_os = "macos")]
478 if check_dir(_home.join("Library/Application Support/Code/User")) {
479 return true;
480 }
481 #[cfg(target_os = "linux")]
482 if check_dir(_home.join(".config/Code/User")) {
483 return true;
484 }
485 #[cfg(target_os = "windows")]
486 if let Ok(appdata) = std::env::var("APPDATA") {
487 if check_dir(PathBuf::from(&appdata).join("Code/User")) {
488 return true;
489 }
490 }
491 false
492}
493
494fn detect_jetbrains_installed(home: &std::path::Path) -> bool {
495 #[cfg(target_os = "macos")]
496 if home.join("Library/Application Support/JetBrains").exists() {
497 return true;
498 }
499 #[cfg(target_os = "linux")]
500 if home.join(".config/JetBrains").exists() {
501 return true;
502 }
503 home.join(".jb-mcp.json").exists()
504}
505
506fn detect_extension_installed(_home: &std::path::Path, extension_id: &str) -> bool {
507 #[cfg(target_os = "macos")]
508 {
509 if _home
510 .join(format!(
511 "Library/Application Support/Code/User/globalStorage/{extension_id}"
512 ))
513 .exists()
514 {
515 return true;
516 }
517 }
518 #[cfg(target_os = "linux")]
519 {
520 if _home
521 .join(format!(".config/Code/User/globalStorage/{extension_id}"))
522 .exists()
523 {
524 return true;
525 }
526 }
527 #[cfg(target_os = "windows")]
528 {
529 if let Ok(appdata) = std::env::var("APPDATA") {
530 if std::path::PathBuf::from(&appdata)
531 .join(format!("Code/User/globalStorage/{extension_id}"))
532 .exists()
533 {
534 return true;
535 }
536 }
537 }
538 false
539}
540
541fn build_rules_targets(home: &std::path::Path) -> Vec<RulesTarget> {
546 vec![
547 RulesTarget {
549 name: "Claude Code",
550 path: crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
551 format: RulesFormat::DedicatedMarkdown,
552 },
553 RulesTarget {
554 name: "Gemini CLI",
555 path: home.join(".gemini/GEMINI.md"),
556 format: RulesFormat::SharedMarkdown,
557 },
558 RulesTarget {
559 name: "VS Code",
560 path: copilot_instructions_path(home),
561 format: RulesFormat::SharedMarkdown,
562 },
563 RulesTarget {
564 name: "Copilot CLI",
565 path: home.join(".copilot/instructions.md"),
566 format: RulesFormat::SharedMarkdown,
567 },
568 RulesTarget {
570 name: "Cursor",
571 path: home.join(".cursor/rules/lean-ctx.mdc"),
572 format: RulesFormat::CursorMdc,
573 },
574 RulesTarget {
575 name: "Windsurf",
576 path: home.join(".codeium/windsurf/rules/lean-ctx.md"),
577 format: RulesFormat::DedicatedMarkdown,
578 },
579 RulesTarget {
580 name: "Zed",
581 path: home.join(".config/zed/rules/lean-ctx.md"),
582 format: RulesFormat::DedicatedMarkdown,
583 },
584 RulesTarget {
585 name: "Cline",
586 path: home.join(".cline/rules/lean-ctx.md"),
587 format: RulesFormat::DedicatedMarkdown,
588 },
589 RulesTarget {
590 name: "Roo Code",
591 path: home.join(".roo/rules/lean-ctx.md"),
592 format: RulesFormat::DedicatedMarkdown,
593 },
594 RulesTarget {
595 name: "OpenCode",
596 path: home.join(".config/opencode/AGENTS.md"),
597 format: RulesFormat::SharedMarkdown,
598 },
599 RulesTarget {
600 name: "Continue",
601 path: home.join(".continue/rules/lean-ctx.md"),
602 format: RulesFormat::DedicatedMarkdown,
603 },
604 RulesTarget {
605 name: "Amp",
606 path: home.join(".ampcoder/rules/lean-ctx.md"),
607 format: RulesFormat::DedicatedMarkdown,
608 },
609 RulesTarget {
610 name: "Qwen Code",
611 path: home.join(".qwen/rules/lean-ctx.md"),
612 format: RulesFormat::DedicatedMarkdown,
613 },
614 RulesTarget {
615 name: "Trae",
616 path: home.join(".trae/rules/lean-ctx.md"),
617 format: RulesFormat::DedicatedMarkdown,
618 },
619 RulesTarget {
620 name: "Amazon Q Developer",
621 path: home.join(".aws/amazonq/rules/lean-ctx.md"),
622 format: RulesFormat::DedicatedMarkdown,
623 },
624 RulesTarget {
625 name: "JetBrains IDEs",
626 path: home.join(".jb-rules/lean-ctx.md"),
627 format: RulesFormat::DedicatedMarkdown,
628 },
629 RulesTarget {
630 name: "Antigravity",
631 path: home.join(".gemini/antigravity/rules/lean-ctx.md"),
632 format: RulesFormat::DedicatedMarkdown,
633 },
634 RulesTarget {
635 name: "Pi Coding Agent",
636 path: home.join(".pi/rules/lean-ctx.md"),
637 format: RulesFormat::DedicatedMarkdown,
638 },
639 RulesTarget {
640 name: "AWS Kiro",
641 path: home.join(".kiro/steering/lean-ctx.md"),
642 format: RulesFormat::DedicatedMarkdown,
643 },
644 RulesTarget {
645 name: "Verdent",
646 path: home.join(".verdent/rules/lean-ctx.md"),
647 format: RulesFormat::DedicatedMarkdown,
648 },
649 RulesTarget {
650 name: "Crush",
651 path: home.join(".config/crush/rules/lean-ctx.md"),
652 format: RulesFormat::DedicatedMarkdown,
653 },
654 RulesTarget {
655 name: "Augment",
656 path: home.join(".augment/rules/lean-ctx.md"),
657 format: RulesFormat::DedicatedMarkdown,
658 },
659 ]
660}
661
662fn copilot_instructions_path(home: &std::path::Path) -> PathBuf {
663 #[cfg(target_os = "macos")]
664 {
665 return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
666 }
667 #[cfg(target_os = "linux")]
668 {
669 return home.join(".config/Code/User/github-copilot-instructions.md");
670 }
671 #[cfg(target_os = "windows")]
672 {
673 if let Ok(appdata) = std::env::var("APPDATA") {
674 return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
675 }
676 }
677 #[allow(unreachable_code)]
678 home.join(".config/Code/User/github-copilot-instructions.md")
679}
680
681const SKILL_TEMPLATE: &str = include_str!("templates/SKILL.md");
686
687struct SkillTarget {
688 agent_key: &'static str,
689 display_name: &'static str,
690 skill_dir: PathBuf,
691}
692
693fn build_skill_targets(home: &std::path::Path) -> Vec<SkillTarget> {
694 vec![
695 SkillTarget {
696 agent_key: "claude",
697 display_name: "Claude Code",
698 skill_dir: crate::setup::claude_config_dir(home).join("skills/lean-ctx"),
699 },
700 SkillTarget {
701 agent_key: "cursor",
702 display_name: "Cursor",
703 skill_dir: home.join(".cursor/skills/lean-ctx"),
704 },
705 SkillTarget {
706 agent_key: "codex",
707 display_name: "Codex CLI",
708 skill_dir: crate::core::home::resolve_codex_dir()
709 .unwrap_or_else(|| home.join(".codex"))
710 .join("skills/lean-ctx"),
711 },
712 SkillTarget {
713 agent_key: "copilot",
714 display_name: "GitHub Copilot",
715 skill_dir: home.join(".copilot/skills/lean-ctx"),
716 },
717 ]
718}
719
720fn is_skill_agent_detected(agent_key: &str, home: &std::path::Path) -> bool {
721 match agent_key {
722 "claude" => {
723 command_exists("claude")
724 || crate::core::editor_registry::claude_mcp_json_path(home).exists()
725 || crate::core::editor_registry::claude_state_dir(home).exists()
726 }
727 "cursor" => home.join(".cursor").exists(),
728 "codex" => {
729 let codex_dir =
730 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
731 codex_dir.exists() || command_exists("codex")
732 }
733 "copilot" => {
734 home.join(".copilot").exists()
735 || home.join(".copilot/mcp-config.json").exists()
736 || command_exists("copilot")
737 }
738 _ => false,
739 }
740}
741
742pub fn install_skill_for_agent(home: &std::path::Path, agent_key: &str) -> Result<PathBuf, String> {
744 let targets = build_skill_targets(home);
745 let target = targets
746 .into_iter()
747 .find(|t| t.agent_key == agent_key)
748 .ok_or_else(|| format!("No skill target for agent '{agent_key}'"))?;
749
750 let skill_path = target.skill_dir.join("SKILL.md");
751 std::fs::create_dir_all(&target.skill_dir).map_err(|e| e.to_string())?;
752
753 if skill_path.exists() {
754 let existing = std::fs::read_to_string(&skill_path).unwrap_or_default();
755 if existing == SKILL_TEMPLATE {
756 return Ok(skill_path);
757 }
758 }
759
760 crate::config_io::write_atomic_with_backup(&skill_path, SKILL_TEMPLATE)?;
761 Ok(skill_path)
762}
763
764pub fn install_all_skills(home: &std::path::Path) -> Vec<(String, bool)> {
767 let targets = build_skill_targets(home);
768 let mut results = Vec::new();
769
770 for target in &targets {
771 if !is_skill_agent_detected(target.agent_key, home) {
772 continue;
773 }
774
775 let skill_path = target.skill_dir.join("SKILL.md");
776 let already_current = skill_path.exists()
777 && std::fs::read_to_string(&skill_path).is_ok_and(|c| c == SKILL_TEMPLATE);
778
779 if already_current {
780 results.push((target.display_name.to_string(), false));
781 continue;
782 }
783
784 if let Err(e) = std::fs::create_dir_all(&target.skill_dir) {
785 tracing::warn!(
786 "Failed to create skill dir for {}: {e}",
787 target.display_name
788 );
789 continue;
790 }
791
792 match crate::config_io::write_atomic_with_backup(&skill_path, SKILL_TEMPLATE) {
793 Ok(()) => results.push((target.display_name.to_string(), true)),
794 Err(e) => {
795 tracing::warn!("Failed to write SKILL.md for {}: {e}", target.display_name);
796 }
797 }
798 }
799
800 results
801}
802
803#[cfg(test)]
808mod tests {
809 use super::*;
810
811 #[test]
812 fn shared_rules_have_markers() {
813 assert!(RULES_SHARED.contains(MARKER));
814 assert!(RULES_SHARED.contains(END_MARKER));
815 assert!(RULES_SHARED.contains(RULES_VERSION));
816 }
817
818 #[test]
819 fn dedicated_rules_have_markers() {
820 assert!(RULES_DEDICATED.contains(MARKER));
821 assert!(RULES_DEDICATED.contains(END_MARKER));
822 assert!(RULES_DEDICATED.contains(RULES_VERSION));
823 }
824
825 #[test]
826 fn cursor_mdc_has_markers_and_frontmatter() {
827 assert!(RULES_CURSOR_MDC.contains("lean-ctx"));
828 assert!(RULES_CURSOR_MDC.contains(END_MARKER));
829 assert!(RULES_CURSOR_MDC.contains(RULES_VERSION));
830 assert!(RULES_CURSOR_MDC.contains("alwaysApply: true"));
831 }
832
833 #[test]
834 fn shared_rules_contain_mode_selection() {
835 assert!(RULES_SHARED.contains("Mode Selection"));
836 assert!(RULES_SHARED.contains("full"));
837 assert!(RULES_SHARED.contains("map"));
838 assert!(RULES_SHARED.contains("signatures"));
839 assert!(RULES_SHARED.contains("NEVER"));
840 }
841
842 #[test]
843 fn shared_rules_has_anti_pattern() {
844 assert!(RULES_SHARED.contains("Anti-pattern"));
845 assert!(RULES_SHARED.contains("NEVER use `full`"));
846 }
847
848 #[test]
849 fn dedicated_rules_contain_modes() {
850 assert!(RULES_DEDICATED.contains("auto"));
851 assert!(RULES_DEDICATED.contains("full"));
852 assert!(RULES_DEDICATED.contains("map"));
853 assert!(RULES_DEDICATED.contains("signatures"));
854 assert!(RULES_DEDICATED.contains("entropy"));
855 assert!(RULES_DEDICATED.contains("aggressive"));
856 assert!(RULES_DEDICATED.contains("task"));
857 assert!(RULES_DEDICATED.contains("lines:N-M"));
858 }
859
860 #[test]
861 fn dedicated_rules_has_proactive_section() {
862 assert!(RULES_DEDICATED.contains("Proactive"));
863 assert!(RULES_DEDICATED.contains("ctx_overview"));
864 assert!(RULES_DEDICATED.contains("ctx_compress"));
865 }
866
867 #[test]
868 fn cursor_mdc_contains_mode_selection() {
869 assert!(RULES_CURSOR_MDC.contains("Mode Selection"));
870 assert!(RULES_CURSOR_MDC.contains("ctx_read"));
871 assert!(RULES_CURSOR_MDC.contains("ctx_search"));
872 assert!(RULES_CURSOR_MDC.contains("lean-ctx -c"));
873 }
874
875 fn ensure_temp_dir() {
876 let tmp = std::env::temp_dir();
877 if !tmp.exists() {
878 std::fs::create_dir_all(&tmp).ok();
879 }
880 }
881
882 #[test]
883 fn replace_section_with_end_marker() {
884 ensure_temp_dir();
885 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";
886 let path = std::env::temp_dir().join("test_replace_with_end.md");
887 std::fs::write(&path, old).unwrap();
888
889 let result = replace_markdown_section(&path, old).unwrap();
890 assert!(matches!(result, RulesResult::Updated));
891
892 let new_content = std::fs::read_to_string(&path).unwrap();
893 assert!(new_content.contains(RULES_VERSION));
894 assert!(new_content.starts_with("user stuff"));
895 assert!(new_content.contains("more user stuff"));
896 assert!(!new_content.contains("lean-ctx-rules-v2"));
897
898 std::fs::remove_file(&path).ok();
899 }
900
901 #[test]
902 fn replace_section_without_end_marker() {
903 ensure_temp_dir();
904 let old = "user stuff\n\n# lean-ctx — Context Engineering Layer\nold rules only\n";
905 let path = std::env::temp_dir().join("test_replace_no_end.md");
906 std::fs::write(&path, old).unwrap();
907
908 let result = replace_markdown_section(&path, old).unwrap();
909 assert!(matches!(result, RulesResult::Updated));
910
911 let new_content = std::fs::read_to_string(&path).unwrap();
912 assert!(new_content.contains(RULES_VERSION));
913 assert!(new_content.starts_with("user stuff"));
914
915 std::fs::remove_file(&path).ok();
916 }
917
918 #[test]
919 fn append_to_shared_preserves_existing() {
920 ensure_temp_dir();
921 let path = std::env::temp_dir().join("test_append_shared.md");
922 std::fs::write(&path, "existing user rules\n").unwrap();
923
924 let result = append_to_shared(&path).unwrap();
925 assert!(matches!(result, RulesResult::Injected));
926
927 let content = std::fs::read_to_string(&path).unwrap();
928 assert!(content.starts_with("existing user rules"));
929 assert!(content.contains(MARKER));
930 assert!(content.contains(END_MARKER));
931
932 std::fs::remove_file(&path).ok();
933 }
934
935 #[test]
936 fn write_dedicated_creates_file() {
937 ensure_temp_dir();
938 let path = std::env::temp_dir().join("test_write_dedicated.md");
939 if path.exists() {
940 std::fs::remove_file(&path).ok();
941 }
942
943 let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
944 assert!(matches!(result, RulesResult::Injected));
945
946 let content = std::fs::read_to_string(&path).unwrap();
947 assert!(content.contains(MARKER));
948 assert!(content.contains("Mode Selection"));
949
950 std::fs::remove_file(&path).ok();
951 }
952
953 #[test]
954 fn write_dedicated_updates_existing() {
955 ensure_temp_dir();
956 let path = std::env::temp_dir().join("test_write_dedicated_update.md");
957 std::fs::write(&path, "# lean-ctx — Context Engineering Layer\nold version").unwrap();
958
959 let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
960 assert!(matches!(result, RulesResult::Updated));
961
962 std::fs::remove_file(&path).ok();
963 }
964
965 #[test]
966 fn target_count() {
967 let home = std::path::PathBuf::from("/tmp/fake_home");
968 let targets = build_rules_targets(&home);
969 assert_eq!(targets.len(), 22);
970 }
971
972 #[test]
973 fn skill_template_not_empty() {
974 assert!(!SKILL_TEMPLATE.is_empty());
975 assert!(SKILL_TEMPLATE.contains("lean-ctx"));
976 }
977
978 #[test]
979 fn skill_targets_count() {
980 let home = std::path::PathBuf::from("/tmp/fake_home");
981 let targets = build_skill_targets(&home);
982 assert_eq!(targets.len(), 4);
983 }
984
985 #[test]
986 fn install_skill_creates_file() {
987 ensure_temp_dir();
988 let home = std::env::temp_dir().join("test_skill_install");
989 let _ = std::fs::create_dir_all(&home);
990
991 let fake_cursor = home.join(".cursor");
992 let _ = std::fs::create_dir_all(&fake_cursor);
993
994 let result = install_skill_for_agent(&home, "cursor");
995 assert!(result.is_ok());
996
997 let path = result.unwrap();
998 assert!(path.exists());
999 let content = std::fs::read_to_string(&path).unwrap();
1000 assert_eq!(content, SKILL_TEMPLATE);
1001
1002 let _ = std::fs::remove_dir_all(&home);
1003 }
1004
1005 #[test]
1006 fn install_skill_idempotent() {
1007 ensure_temp_dir();
1008 let home = std::env::temp_dir().join("test_skill_idempotent");
1009 let _ = std::fs::create_dir_all(&home);
1010
1011 let fake_cursor = home.join(".cursor");
1012 let _ = std::fs::create_dir_all(&fake_cursor);
1013
1014 let p1 = install_skill_for_agent(&home, "cursor").unwrap();
1015 let p2 = install_skill_for_agent(&home, "cursor").unwrap();
1016 assert_eq!(p1, p2);
1017
1018 let _ = std::fs::remove_dir_all(&home);
1019 }
1020
1021 #[test]
1022 fn install_skill_unknown_agent() {
1023 let home = std::path::PathBuf::from("/tmp/fake_home");
1024 let result = install_skill_for_agent(&home, "unknown_agent");
1025 assert!(result.is_err());
1026 }
1027
1028 #[test]
1029 fn match_agent_name_basic() {
1030 assert!(match_agent_name("cursor", "Cursor"));
1031 assert!(match_agent_name("opencode", "OpenCode"));
1032 assert!(match_agent_name("claude", "Claude Code"));
1033 assert!(match_agent_name("vscode", "VS Code"));
1034 assert!(match_agent_name("copilot", "Copilot CLI"));
1035 assert!(match_agent_name("kiro", "AWS Kiro"));
1036 assert!(match_agent_name("pi", "Pi Coding Agent"));
1037 assert!(match_agent_name("crush", "Crush"));
1038 assert!(match_agent_name("amp", "Amp"));
1039 assert!(match_agent_name("cline", "Cline"));
1040 assert!(match_agent_name("roo", "Roo Code"));
1041 assert!(match_agent_name("trae", "Trae"));
1042 assert!(match_agent_name("amazonq", "Amazon Q Developer"));
1043 assert!(match_agent_name("verdent", "Verdent"));
1044 assert!(match_agent_name("continue", "Continue"));
1045 assert!(match_agent_name("antigravity", "Antigravity"));
1046 assert!(match_agent_name("gemini", "Gemini CLI"));
1047 assert!(match_agent_name("augment", "Augment"));
1048 }
1049
1050 #[test]
1051 fn match_agent_name_no_false_positives() {
1052 assert!(!match_agent_name("cursor", "Claude Code"));
1053 assert!(!match_agent_name("opencode", "Cursor"));
1054 assert!(!match_agent_name("unknown_agent", "Cursor"));
1055 }
1056
1057 #[test]
1058 fn inject_rules_for_agent_opencode() {
1059 ensure_temp_dir();
1060 let home = std::env::temp_dir().join("test_inject_rules_agent");
1061 let _ = std::fs::remove_dir_all(&home);
1062 let _ = std::fs::create_dir_all(&home);
1063
1064 let opencode_dir = home.join(".config/opencode");
1065 let _ = std::fs::create_dir_all(&opencode_dir);
1066
1067 let result = inject_rules_for_agent(&home, "opencode");
1068 assert!(
1069 !result.injected.is_empty() || !result.already.is_empty(),
1070 "should inject or find rules for OpenCode"
1071 );
1072 assert!(result.errors.is_empty(), "no errors expected");
1073
1074 let agents_md = opencode_dir.join("AGENTS.md");
1075 if agents_md.exists() {
1076 let content = std::fs::read_to_string(&agents_md).unwrap();
1077 assert!(content.contains(RULES_VERSION));
1078 }
1079
1080 let _ = std::fs::remove_dir_all(&home);
1081 }
1082
1083 #[test]
1084 fn inject_rules_for_agent_cursor() {
1085 ensure_temp_dir();
1086 let home = std::env::temp_dir().join("test_inject_rules_cursor");
1087 let _ = std::fs::remove_dir_all(&home);
1088 let _ = std::fs::create_dir_all(&home);
1089
1090 let cursor_dir = home.join(".cursor");
1091 let _ = std::fs::create_dir_all(&cursor_dir);
1092
1093 let result = inject_rules_for_agent(&home, "cursor");
1094 assert!(result.errors.is_empty(), "no errors expected");
1095
1096 let mdc_path = home.join(".cursor/rules/lean-ctx.mdc");
1097 if mdc_path.exists() {
1098 let content = std::fs::read_to_string(&mdc_path).unwrap();
1099 assert!(content.contains(RULES_VERSION));
1100 }
1101
1102 let _ = std::fs::remove_dir_all(&home);
1103 }
1104
1105 #[test]
1106 fn inject_rules_for_unknown_agent_is_empty() {
1107 let home = std::path::PathBuf::from("/tmp/fake_home_unknown");
1108 let result = inject_rules_for_agent(&home, "unknown_agent_xyz");
1109 assert!(result.injected.is_empty());
1110 assert!(result.updated.is_empty());
1111 assert!(result.already.is_empty());
1112 assert!(result.errors.is_empty());
1113 }
1114}