1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5const MARKER: &str = "# nebu-ctx — Context Engineering Layer";
6const LEGACY_MARKER: &str = "# lean-ctx — Context Engineering Layer";
7const END_MARKER: &str = "<!-- /lean-ctx -->";
8const RULES_VERSION: &str = "nebu-ctx-rules-v9";
9const LEGACY_RULES_VERSION: &str = "lean-ctx-rules-v9";
10
11pub const RULES_MARKER: &str = MARKER;
12pub const RULES_VERSION_STR: &str = RULES_VERSION;
13
14pub fn rules_dedicated_markdown() -> &'static str {
15 RULES_DEDICATED
16}
17
18const RULES_SHARED: &str = r#"# nebu-ctx — Context Engineering Layer
24<!-- nebu-ctx-rules-v9 -->
25
26CRITICAL: ALWAYS use nebu-ctx MCP tools instead of native equivalents. This is NOT optional.
27
28| ALWAYS USE | NEVER USE | Why |
29|------------|-----------|-----|
30| `ctx_read(path, mode)` | `Read` / `cat` / `head` / `tail` | Cached, 10 read modes, re-reads ~13 tokens |
31| `ctx_shell(command)` | `Shell` / `bash` / terminal | Pattern compression for git/npm/cargo output |
32| `ctx_search(pattern, path)` | `Grep` / `rg` | Compact, token-efficient results |
33| `ctx_tree(path, depth)` | `ls` / `find` | Compact directory maps |
34
35Compatibility: ctx_read replaces READ operations only. Your native Edit/Write/StrReplace tools remain unchanged — keep using them for editing. If your rules say "use Edit or Write tools only", that is compatible: lean-ctx only replaces how you READ files, not how you EDIT them.
36
37If Edit requires native Read and Read is unavailable, use `ctx_edit(path, old_string, new_string)` instead.
38Write, Delete, Glob → use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
39
40Preferred workflow control: use `ctx_workflow` to track states + enforce tool gates + evidence.
41
42Fallback only if a nebu-ctx tool is unavailable: use native equivalents.
43<!-- /lean-ctx -->"#;
44
45const RULES_DEDICATED: &str = r#"# nebu-ctx — Context Engineering Layer
51<!-- nebu-ctx-rules-v9 -->
52
53PREFER nebu-ctx MCP tools over native equivalents for token savings:
54
55## Tool preference:
56| PREFER | OVER | Why |
57|--------|------|-----|
58| `ctx_read(path, mode)` | `Read` / `cat` | Cached, 10 read modes, re-reads ~13 tokens |
59| `ctx_shell(command)` | `Shell` / `bash` | Pattern compression for git/npm/cargo output |
60| `ctx_search(pattern, path)` | `Grep` / `rg` | Compact, token-efficient results |
61| `ctx_tree(path, depth)` | `ls` / `find` | Compact directory maps |
62| `ctx_edit(path, old_string, new_string)` | `Edit` (when Read unavailable) | Search-and-replace without native Read |
63
64## ctx_read modes:
65- `auto` — auto-select optimal mode (recommended default)
66- `full` — cached read (files you edit)
67- `map` — deps + exports (context-only files)
68- `signatures` — API surface only
69- `diff` — changed lines after edits
70- `aggressive` — maximum compression (context only)
71- `entropy` — highlight high-entropy fragments
72- `task` — IB-filtered (task relevant)
73- `reference` — quote-friendly minimal excerpts
74- `lines:N-M` — specific range
75
76## File editing:
77Use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
78Write, Delete, Glob → use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
79
80## Proactive (use without being asked):
81- `ctx_overview(task)` at session start
82- `ctx_compress` when context grows large
83<!-- /lean-ctx -->"#;
84
85const RULES_CURSOR_MDC: &str = r#"---
91description: "lean-ctx: prefer ctx_read/ctx_shell/ctx_search/ctx_tree over Read/Shell/Grep/ls for token savings"
92alwaysApply: true
93---
94
95# nebu-ctx — Context Engineering Layer
96<!-- nebu-ctx-rules-v9 -->
97
98PREFER nebu-ctx MCP tools over native equivalents for token savings:
99
100## Tool Mapping
101
102| PREFER | OVER | Why |
103|--------|------|-----|
104| `ctx_read` | `Read` | Session caching, 10 read modes, re-reads cost ~13 tokens |
105| `ctx_shell` | `Shell` | Pattern-based compression for git, npm, cargo, docker, tsc |
106| `ctx_search` | `Grep` | Compact context, token-efficient results |
107| `ctx_tree` | `ls`, `find` | Compact directory maps with file counts |
108| `ctx_edit` | `Edit` (when Read unavailable) | Search-and-replace without native Read dependency |
109
110## ctx_read Modes
111
112- `auto` — auto-select optimal mode (recommended default)
113- `full` — cached read (use for files you will edit)
114- `map` — dependency graph + exports + key signatures (use for context-only files)
115- `signatures` — API surface only
116- `diff` — changed lines only (use after edits)
117- `aggressive` — maximum compression (context only)
118- `entropy` — highlight high-entropy fragments
119- `task` — IB-filtered (task relevant)
120- `reference` — quote-friendly minimal excerpts
121- `lines:N-M` — specific range
122
123## File editing
124
125- Use native Edit/StrReplace when available.
126- If Edit requires native Read and Read is unavailable: use `ctx_edit(path, old_string, new_string)` instead.
127- NEVER loop trying to make Edit work. If it fails, switch to ctx_edit immediately.
128- Write, Delete, Glob → use normally.
129- Fallback only if a nebu-ctx tool is unavailable: use native equivalents.
130<!-- /lean-ctx -->"#;
131
132struct RulesTarget {
135 name: &'static str,
136 path: PathBuf,
137 format: RulesFormat,
138}
139
140enum RulesFormat {
141 SharedMarkdown,
142 DedicatedMarkdown,
143 CursorMdc,
144}
145
146pub struct InjectResult {
147 pub injected: Vec<String>,
148 pub updated: Vec<String>,
149 pub already: Vec<String>,
150 pub errors: Vec<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct RulesTargetStatus {
155 pub name: String,
156 pub detected: bool,
157 pub path: String,
158 pub state: String,
159 pub note: Option<String>,
160}
161
162pub fn inject_all_rules(home: &std::path::Path) -> InjectResult {
163 if crate::core::config::Config::load().rules_scope_effective()
164 == crate::core::config::RulesScope::Project
165 {
166 return InjectResult {
167 injected: Vec::new(),
168 updated: Vec::new(),
169 already: Vec::new(),
170 errors: Vec::new(),
171 };
172 }
173
174 let targets = build_rules_targets(home);
175
176 let mut result = InjectResult {
177 injected: Vec::new(),
178 updated: Vec::new(),
179 already: Vec::new(),
180 errors: Vec::new(),
181 };
182
183 for target in &targets {
184 if !is_tool_detected(target, home) {
185 continue;
186 }
187
188 match inject_rules(target) {
189 Ok(RulesResult::Injected) => result.injected.push(target.name.to_string()),
190 Ok(RulesResult::Updated) => result.updated.push(target.name.to_string()),
191 Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
192 Err(e) => result.errors.push(format!("{}: {e}", target.name)),
193 }
194 }
195
196 result
197}
198
199pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
200 let targets = build_rules_targets(home);
201 let mut out = Vec::new();
202
203 for target in &targets {
204 let detected = is_tool_detected(target, home);
205 let path = target.path.to_string_lossy().to_string();
206
207 let state = if !detected {
208 "not_detected".to_string()
209 } else if !target.path.exists() {
210 "missing".to_string()
211 } else {
212 match std::fs::read_to_string(&target.path) {
213 Ok(content) => {
214 if content.contains(MARKER) || content.contains(LEGACY_MARKER) {
215 if content.contains(RULES_VERSION)
216 || content.contains(LEGACY_RULES_VERSION)
217 {
218 "up_to_date".to_string()
219 } else {
220 "outdated".to_string()
221 }
222 } else {
223 "present_without_marker".to_string()
224 }
225 }
226 Err(_) => "read_error".to_string(),
227 }
228 };
229
230 out.push(RulesTargetStatus {
231 name: target.name.to_string(),
232 detected,
233 path,
234 state,
235 note: None,
236 });
237 }
238
239 out
240}
241
242enum RulesResult {
247 Injected,
248 Updated,
249 AlreadyPresent,
250}
251
252fn rules_content(format: &RulesFormat) -> &'static str {
253 match format {
254 RulesFormat::SharedMarkdown => RULES_SHARED,
255 RulesFormat::DedicatedMarkdown => RULES_DEDICATED,
256 RulesFormat::CursorMdc => RULES_CURSOR_MDC,
257 }
258}
259
260fn inject_rules(target: &RulesTarget) -> Result<RulesResult, String> {
261 if target.path.exists() {
262 let content = std::fs::read_to_string(&target.path).map_err(|e| e.to_string())?;
263 if content.contains(MARKER) || content.contains(LEGACY_MARKER) {
264 if content.contains(RULES_VERSION) || content.contains(LEGACY_RULES_VERSION) {
265 return Ok(RulesResult::AlreadyPresent);
266 }
267 ensure_parent(&target.path)?;
268 return match target.format {
269 RulesFormat::SharedMarkdown => replace_markdown_section(&target.path, &content),
270 RulesFormat::DedicatedMarkdown | RulesFormat::CursorMdc => {
271 write_dedicated(&target.path, rules_content(&target.format))
272 }
273 };
274 }
275 }
276
277 ensure_parent(&target.path)?;
278
279 match target.format {
280 RulesFormat::SharedMarkdown => append_to_shared(&target.path),
281 RulesFormat::DedicatedMarkdown | RulesFormat::CursorMdc => {
282 write_dedicated(&target.path, rules_content(&target.format))
283 }
284 }
285}
286
287fn ensure_parent(path: &std::path::Path) -> Result<(), String> {
288 if let Some(parent) = path.parent() {
289 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
290 }
291 Ok(())
292}
293
294fn append_to_shared(path: &std::path::Path) -> Result<RulesResult, String> {
295 let mut content = if path.exists() {
296 std::fs::read_to_string(path).map_err(|e| e.to_string())?
297 } else {
298 String::new()
299 };
300
301 if !content.is_empty() && !content.ends_with('\n') {
302 content.push('\n');
303 }
304 if !content.is_empty() {
305 content.push('\n');
306 }
307 content.push_str(RULES_SHARED);
308 content.push('\n');
309
310 std::fs::write(path, content).map_err(|e| e.to_string())?;
311 Ok(RulesResult::Injected)
312}
313
314fn replace_markdown_section(path: &std::path::Path, content: &str) -> Result<RulesResult, String> {
315 let start = content.find(MARKER).or_else(|| content.find(LEGACY_MARKER));
316 let end = content.find(END_MARKER);
317
318 let new_content = match (start, end) {
319 (Some(s), Some(e)) => {
320 let before = &content[..s];
321 let after_end = e + END_MARKER.len();
322 let after = content[after_end..].trim_start_matches('\n');
323 let mut result = before.to_string();
324 result.push_str(RULES_SHARED);
325 if !after.is_empty() {
326 result.push('\n');
327 result.push_str(after);
328 }
329 result
330 }
331 (Some(s), None) => {
332 let before = &content[..s];
333 let mut result = before.to_string();
334 result.push_str(RULES_SHARED);
335 result.push('\n');
336 result
337 }
338 _ => return Ok(RulesResult::AlreadyPresent),
339 };
340
341 std::fs::write(path, new_content).map_err(|e| e.to_string())?;
342 Ok(RulesResult::Updated)
343}
344
345fn write_dedicated(path: &std::path::Path, content: &'static str) -> Result<RulesResult, String> {
346 let is_update = path.exists() && {
347 let existing = std::fs::read_to_string(path).unwrap_or_default();
348 existing.contains(MARKER) || existing.contains(LEGACY_MARKER)
349 };
350
351 std::fs::write(path, content).map_err(|e| e.to_string())?;
352
353 if is_update {
354 Ok(RulesResult::Updated)
355 } else {
356 Ok(RulesResult::Injected)
357 }
358}
359
360fn is_tool_detected(target: &RulesTarget, home: &std::path::Path) -> bool {
365 match target.name {
366 "Claude Code" => {
367 if command_exists("claude") {
368 return true;
369 }
370 let state_dir = crate::core::editor_registry::claude_state_dir(home);
371 crate::core::editor_registry::claude_mcp_json_path(home).exists() || state_dir.exists()
372 }
373 "Codex CLI" => home.join(".codex").exists() || command_exists("codex"),
374 "Cursor" => home.join(".cursor").exists(),
375 "Windsurf" => home.join(".codeium/windsurf").exists(),
376 "Gemini CLI" => home.join(".gemini").exists(),
377 "VS Code / Copilot" => detect_vscode_installed(home),
378 "Zed" => home.join(".config/zed").exists(),
379 "Cline" => detect_extension_installed(home, "saoudrizwan.claude-dev"),
380 "Roo Code" => detect_extension_installed(home, "rooveterinaryinc.roo-cline"),
381 "OpenCode" => home.join(".config/opencode").exists(),
382 "Continue" => detect_extension_installed(home, "continue.continue"),
383 "Aider" => command_exists("aider") || home.join(".aider.conf.yml").exists(),
384 "Amp" => command_exists("amp") || home.join(".ampcoder").exists(),
385 "Qwen Code" => home.join(".qwen").exists(),
386 "Trae" => home.join(".trae").exists(),
387 "Amazon Q Developer" => home.join(".aws/amazonq").exists(),
388 "JetBrains IDEs" => detect_jetbrains_installed(home),
389 "Antigravity" => home.join(".gemini/antigravity").exists(),
390 "Pi Coding Agent" => home.join(".pi").exists() || command_exists("pi"),
391 "AWS Kiro" => home.join(".kiro").exists(),
392 "Crush" => home.join(".config/crush").exists() || command_exists("crush"),
393 "Verdent" => home.join(".verdent").exists(),
394 _ => false,
395 }
396}
397
398fn command_exists(name: &str) -> bool {
399 #[cfg(target_os = "windows")]
400 let result = std::process::Command::new("where")
401 .arg(name)
402 .output()
403 .map(|o| o.status.success())
404 .unwrap_or(false);
405
406 #[cfg(not(target_os = "windows"))]
407 let result = std::process::Command::new("which")
408 .arg(name)
409 .output()
410 .map(|o| o.status.success())
411 .unwrap_or(false);
412
413 result
414}
415
416fn detect_vscode_installed(_home: &std::path::Path) -> bool {
417 let check_dir = |dir: PathBuf| -> bool {
418 dir.join("settings.json").exists() || dir.join("mcp.json").exists()
419 };
420
421 #[cfg(target_os = "macos")]
422 if check_dir(_home.join("Library/Application Support/Code/User")) {
423 return true;
424 }
425 #[cfg(target_os = "linux")]
426 if check_dir(_home.join(".config/Code/User")) {
427 return true;
428 }
429 #[cfg(target_os = "windows")]
430 if let Ok(appdata) = std::env::var("APPDATA") {
431 if check_dir(PathBuf::from(&appdata).join("Code/User")) {
432 return true;
433 }
434 }
435 false
436}
437
438fn detect_jetbrains_installed(home: &std::path::Path) -> bool {
439 #[cfg(target_os = "macos")]
440 if home.join("Library/Application Support/JetBrains").exists() {
441 return true;
442 }
443 #[cfg(target_os = "linux")]
444 if home.join(".config/JetBrains").exists() {
445 return true;
446 }
447 home.join(".jb-mcp.json").exists()
448}
449
450fn detect_extension_installed(_home: &std::path::Path, extension_id: &str) -> bool {
451 #[cfg(target_os = "macos")]
452 {
453 if _home
454 .join(format!(
455 "Library/Application Support/Code/User/globalStorage/{extension_id}"
456 ))
457 .exists()
458 {
459 return true;
460 }
461 }
462 #[cfg(target_os = "linux")]
463 {
464 if _home
465 .join(format!(".config/Code/User/globalStorage/{extension_id}"))
466 .exists()
467 {
468 return true;
469 }
470 }
471 #[cfg(target_os = "windows")]
472 {
473 if let Ok(appdata) = std::env::var("APPDATA") {
474 if std::path::PathBuf::from(&appdata)
475 .join(format!("Code/User/globalStorage/{extension_id}"))
476 .exists()
477 {
478 return true;
479 }
480 }
481 }
482 false
483}
484
485fn build_rules_targets(home: &std::path::Path) -> Vec<RulesTarget> {
490 vec![
491 RulesTarget {
493 name: "Claude Code",
494 path: crate::core::editor_registry::claude_rules_dir(home).join("nebu-ctx.md"),
495 format: RulesFormat::DedicatedMarkdown,
496 },
497 RulesTarget {
498 name: "Codex CLI",
499 path: home.join(".codex/instructions.md"),
500 format: RulesFormat::SharedMarkdown,
501 },
502 RulesTarget {
503 name: "Gemini CLI",
504 path: home.join(".gemini/GEMINI.md"),
505 format: RulesFormat::SharedMarkdown,
506 },
507 RulesTarget {
508 name: "VS Code / Copilot",
509 path: copilot_instructions_path(home),
510 format: RulesFormat::SharedMarkdown,
511 },
512 RulesTarget {
514 name: "Cursor",
515 path: home.join(".cursor/rules/nebu-ctx.mdc"),
516 format: RulesFormat::CursorMdc,
517 },
518 RulesTarget {
519 name: "Windsurf",
520 path: home.join(".codeium/windsurf/rules/nebu-ctx.md"),
521 format: RulesFormat::DedicatedMarkdown,
522 },
523 RulesTarget {
524 name: "Zed",
525 path: home.join(".config/zed/rules/nebu-ctx.md"),
526 format: RulesFormat::DedicatedMarkdown,
527 },
528 RulesTarget {
529 name: "Cline",
530 path: home.join(".cline/rules/nebu-ctx.md"),
531 format: RulesFormat::DedicatedMarkdown,
532 },
533 RulesTarget {
534 name: "Roo Code",
535 path: home.join(".roo/rules/nebu-ctx.md"),
536 format: RulesFormat::DedicatedMarkdown,
537 },
538 RulesTarget {
539 name: "OpenCode",
540 path: home.join(".config/opencode/rules/nebu-ctx.md"),
541 format: RulesFormat::DedicatedMarkdown,
542 },
543 RulesTarget {
544 name: "Continue",
545 path: home.join(".continue/rules/nebu-ctx.md"),
546 format: RulesFormat::DedicatedMarkdown,
547 },
548 RulesTarget {
549 name: "Aider",
550 path: home.join(".aider/rules/nebu-ctx.md"),
551 format: RulesFormat::DedicatedMarkdown,
552 },
553 RulesTarget {
554 name: "Amp",
555 path: home.join(".ampcoder/rules/nebu-ctx.md"),
556 format: RulesFormat::DedicatedMarkdown,
557 },
558 RulesTarget {
559 name: "Qwen Code",
560 path: home.join(".qwen/rules/nebu-ctx.md"),
561 format: RulesFormat::DedicatedMarkdown,
562 },
563 RulesTarget {
564 name: "Trae",
565 path: home.join(".trae/rules/nebu-ctx.md"),
566 format: RulesFormat::DedicatedMarkdown,
567 },
568 RulesTarget {
569 name: "Amazon Q Developer",
570 path: home.join(".aws/amazonq/rules/nebu-ctx.md"),
571 format: RulesFormat::DedicatedMarkdown,
572 },
573 RulesTarget {
574 name: "JetBrains IDEs",
575 path: home.join(".jb-rules/nebu-ctx.md"),
576 format: RulesFormat::DedicatedMarkdown,
577 },
578 RulesTarget {
579 name: "Antigravity",
580 path: home.join(".gemini/antigravity/rules/nebu-ctx.md"),
581 format: RulesFormat::DedicatedMarkdown,
582 },
583 RulesTarget {
584 name: "Pi Coding Agent",
585 path: home.join(".pi/rules/nebu-ctx.md"),
586 format: RulesFormat::DedicatedMarkdown,
587 },
588 RulesTarget {
589 name: "AWS Kiro",
590 path: home.join(".kiro/steering/nebu-ctx.md"),
591 format: RulesFormat::DedicatedMarkdown,
592 },
593 RulesTarget {
594 name: "Verdent",
595 path: home.join(".verdent/rules/nebu-ctx.md"),
596 format: RulesFormat::DedicatedMarkdown,
597 },
598 RulesTarget {
599 name: "Crush",
600 path: home.join(".config/crush/rules/nebu-ctx.md"),
601 format: RulesFormat::DedicatedMarkdown,
602 },
603 ]
604}
605
606fn copilot_instructions_path(home: &std::path::Path) -> PathBuf {
607 #[cfg(target_os = "macos")]
608 {
609 return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
610 }
611 #[cfg(target_os = "linux")]
612 {
613 return home.join(".config/Code/User/github-copilot-instructions.md");
614 }
615 #[cfg(target_os = "windows")]
616 {
617 if let Ok(appdata) = std::env::var("APPDATA") {
618 return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
619 }
620 }
621 #[allow(unreachable_code)]
622 home.join(".config/Code/User/github-copilot-instructions.md")
623}
624
625#[cfg(test)]
630mod tests {
631 use super::*;
632
633 #[test]
634 fn shared_rules_have_markers() {
635 assert!(RULES_SHARED.contains(MARKER));
636 assert!(RULES_SHARED.contains(END_MARKER));
637 assert!(RULES_SHARED.contains(RULES_VERSION));
638 }
639
640 #[test]
641 fn dedicated_rules_have_markers() {
642 assert!(RULES_DEDICATED.contains(MARKER));
643 assert!(RULES_DEDICATED.contains(END_MARKER));
644 assert!(RULES_DEDICATED.contains(RULES_VERSION));
645 }
646
647 #[test]
648 fn cursor_mdc_has_markers_and_frontmatter() {
649 assert!(RULES_CURSOR_MDC.contains("lean-ctx"));
650 assert!(RULES_CURSOR_MDC.contains(END_MARKER));
651 assert!(RULES_CURSOR_MDC.contains(RULES_VERSION));
652 assert!(RULES_CURSOR_MDC.contains("alwaysApply: true"));
653 }
654
655 #[test]
656 fn shared_rules_contain_tool_mapping() {
657 assert!(RULES_SHARED.contains("ctx_read"));
658 assert!(RULES_SHARED.contains("ctx_shell"));
659 assert!(RULES_SHARED.contains("ctx_search"));
660 assert!(RULES_SHARED.contains("ctx_tree"));
661 assert!(RULES_SHARED.contains("Write"));
662 }
663
664 #[test]
665 fn shared_rules_litm_optimized() {
666 let lines: Vec<&str> = RULES_SHARED.lines().collect();
667 let first_5 = lines[..5.min(lines.len())].join("\n");
668 assert!(
669 first_5.contains("PREFER") || first_5.contains("nebu-ctx") || first_5.contains("lean-ctx"),
670 "LITM: preference instruction must be near start"
671 );
672 let last_5 = lines[lines.len().saturating_sub(5)..].join("\n");
673 assert!(
674 last_5.contains("fallback") || last_5.contains("native"),
675 "LITM: fallback note must be near end"
676 );
677 }
678
679 #[test]
680 fn dedicated_rules_contain_modes() {
681 assert!(RULES_DEDICATED.contains("auto"));
682 assert!(RULES_DEDICATED.contains("full"));
683 assert!(RULES_DEDICATED.contains("map"));
684 assert!(RULES_DEDICATED.contains("signatures"));
685 assert!(RULES_DEDICATED.contains("diff"));
686 assert!(RULES_DEDICATED.contains("aggressive"));
687 assert!(RULES_DEDICATED.contains("entropy"));
688 assert!(RULES_DEDICATED.contains("task"));
689 assert!(RULES_DEDICATED.contains("reference"));
690 assert!(RULES_DEDICATED.contains("ctx_read"));
691 }
692
693 #[test]
694 fn dedicated_rules_litm_optimized() {
695 let lines: Vec<&str> = RULES_DEDICATED.lines().collect();
696 let first_5 = lines[..5.min(lines.len())].join("\n");
697 assert!(
698 first_5.contains("PREFER") || first_5.contains("nebu-ctx") || first_5.contains("lean-ctx"),
699 "LITM: preference instruction must be near start"
700 );
701 let last_5 = lines[lines.len().saturating_sub(5)..].join("\n");
702 assert!(
703 last_5.contains("fallback") || last_5.contains("ctx_compress"),
704 "LITM: practical note must be near end"
705 );
706 }
707
708 #[test]
709 fn cursor_mdc_litm_optimized() {
710 let lines: Vec<&str> = RULES_CURSOR_MDC.lines().collect();
711 let first_10 = lines[..10.min(lines.len())].join("\n");
712 assert!(
713 first_10.contains("PREFER") || first_10.contains("lean-ctx"),
714 "LITM: preference instruction must be near start of MDC"
715 );
716 let last_5 = lines[lines.len().saturating_sub(5)..].join("\n");
717 assert!(
718 last_5.contains("fallback") || last_5.contains("native"),
719 "LITM: fallback note must be near end of MDC"
720 );
721 }
722
723 fn ensure_temp_dir() {
724 let tmp = std::env::temp_dir();
725 if !tmp.exists() {
726 std::fs::create_dir_all(&tmp).ok();
727 }
728 }
729
730 #[test]
731 fn replace_section_with_end_marker() {
732 ensure_temp_dir();
733 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";
734 let path = std::env::temp_dir().join("test_replace_with_end.md");
735 std::fs::write(&path, old).unwrap();
736
737 let result = replace_markdown_section(&path, old).unwrap();
738 assert!(matches!(result, RulesResult::Updated));
739
740 let new_content = std::fs::read_to_string(&path).unwrap();
741 assert!(new_content.contains(RULES_VERSION));
742 assert!(new_content.starts_with("user stuff"));
743 assert!(new_content.contains("more user stuff"));
744 assert!(!new_content.contains("lean-ctx-rules-v2"));
745
746 std::fs::remove_file(&path).ok();
747 }
748
749 #[test]
750 fn replace_section_without_end_marker() {
751 ensure_temp_dir();
752 let old = "user stuff\n\n# lean-ctx — Context Engineering Layer\nold rules only\n";
753 let path = std::env::temp_dir().join("test_replace_no_end.md");
754 std::fs::write(&path, old).unwrap();
755
756 let result = replace_markdown_section(&path, old).unwrap();
757 assert!(matches!(result, RulesResult::Updated));
758
759 let new_content = std::fs::read_to_string(&path).unwrap();
760 assert!(new_content.contains(RULES_VERSION));
761 assert!(new_content.starts_with("user stuff"));
762
763 std::fs::remove_file(&path).ok();
764 }
765
766 #[test]
767 fn append_to_shared_preserves_existing() {
768 ensure_temp_dir();
769 let path = std::env::temp_dir().join("test_append_shared.md");
770 std::fs::write(&path, "existing user rules\n").unwrap();
771
772 let result = append_to_shared(&path).unwrap();
773 assert!(matches!(result, RulesResult::Injected));
774
775 let content = std::fs::read_to_string(&path).unwrap();
776 assert!(content.starts_with("existing user rules"));
777 assert!(content.contains(MARKER));
778 assert!(content.contains(END_MARKER));
779
780 std::fs::remove_file(&path).ok();
781 }
782
783 #[test]
784 fn write_dedicated_creates_file() {
785 ensure_temp_dir();
786 let path = std::env::temp_dir().join("test_write_dedicated.md");
787 if path.exists() {
788 std::fs::remove_file(&path).ok();
789 }
790
791 let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
792 assert!(matches!(result, RulesResult::Injected));
793
794 let content = std::fs::read_to_string(&path).unwrap();
795 assert!(content.contains(MARKER));
796 assert!(content.contains("ctx_read modes"));
797
798 std::fs::remove_file(&path).ok();
799 }
800
801 #[test]
802 fn write_dedicated_updates_existing() {
803 ensure_temp_dir();
804 let path = std::env::temp_dir().join("test_write_dedicated_update.md");
805 std::fs::write(&path, "# lean-ctx — Context Engineering Layer\nold version").unwrap();
806
807 let result = write_dedicated(&path, RULES_DEDICATED).unwrap();
808 assert!(matches!(result, RulesResult::Updated));
809
810 std::fs::remove_file(&path).ok();
811 }
812
813 #[test]
814 fn target_count() {
815 let home = std::path::PathBuf::from("/tmp/fake_home");
816 let targets = build_rules_targets(&home);
817 assert_eq!(targets.len(), 22);
818 }
819}