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
21pub 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
42pub fn opencode_dedicated_rules_path(home: &std::path::Path) -> PathBuf {
45 home.join(".config/opencode/rules/lean-ctx.md")
46}
47
48pub fn gemini_dedicated_rules_path(home: &std::path::Path) -> PathBuf {
53 home.join(".gemini").join(GEMINI_DEDICATED_CONTEXT_FILENAME)
54}
55
56pub const GEMINI_DEDICATED_CONTEXT_FILENAME: &str = "LEANCTX.md";
58
59const 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
100const 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
155const RULES_CURSOR_MDC: &str = include_str!("templates/lean-ctx.mdc");
159
160struct 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
250pub 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
325pub 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
402enum 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
559fn 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" => {
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
693fn 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 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 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 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 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
867const 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
934pub 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
956pub 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#[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 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 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 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 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}