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