1use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14use crate::core::rules_canonical::RulesFile;
15pub use crate::core::rules_canonical::{END_MARK, RULES_VERSION, START_MARK};
16
17mod content;
18mod detect;
19mod skills;
20mod targets;
21#[cfg(test)]
22mod tests;
23mod write;
24
25pub use content::cursor_mdc_document;
26pub use content::{
27 GEMINI_DEDICATED_CONTEXT_FILENAME, gemini_dedicated_rules_path, opencode_dedicated_rules_path,
28};
29pub use skills::{
30 install_all_skills, install_skill_for_agent, install_skills_where, planned_skill_files,
31};
32
33fn shared_surface_profile(
37 cfg: &crate::core::config::Config,
38) -> crate::core::tool_profiles::ToolProfile {
39 crate::core::tool_visibility::guidance_profile(cfg, "")
40}
41
42pub fn canonical_rules_block() -> String {
44 let cfg = crate::core::config::Config::load();
45 let shadow = cfg.shadow_mode;
46 let level = crate::core::config::CompressionLevel::effective(&cfg);
47 let profile = shared_surface_profile(&cfg);
48 crate::core::rules_canonical::render(
49 shadow,
50 crate::core::rules_canonical::Wrapper::Shared,
51 level,
52 &profile,
53 )
54}
55pub fn rules_shared_content() -> String {
56 canonical_rules_block()
57}
58pub fn rules_dedicated_markdown() -> String {
59 let cfg = crate::core::config::Config::load();
60 let shadow = cfg.shadow_mode;
61 let level = crate::core::config::CompressionLevel::effective(&cfg);
62 let profile = shared_surface_profile(&cfg);
63 crate::core::rules_canonical::render(
64 shadow,
65 crate::core::rules_canonical::Wrapper::Dedicated,
66 level,
67 &profile,
68 )
69}
70
71pub fn rules_longform_markdown() -> String {
75 let cfg = crate::core::config::Config::load();
76 let shadow = cfg.shadow_mode;
77 let level = crate::core::config::CompressionLevel::effective(&cfg);
78 let profile = shared_surface_profile(&cfg);
79 crate::core::rules_canonical::render(
80 shadow,
81 crate::core::rules_canonical::Wrapper::Longform,
82 level,
83 &profile,
84 )
85}
86
87pub fn expected_blocks_by_target(
96 home: &std::path::Path,
97) -> std::collections::HashMap<String, String> {
98 let injection = crate::core::config::Config::load().rules_injection_effective();
99 let cfg = crate::core::config::Config::load();
100 let shadow = cfg.shadow_mode;
101 let level = crate::core::config::CompressionLevel::effective(&cfg);
102 build_rules_targets(home, injection)
103 .into_iter()
104 .map(|target| {
105 let profile = write::profile_for_target(&cfg, target.name);
108 let wrapper = match target.format {
109 RulesFormat::SharedMarkdown => crate::core::rules_canonical::Wrapper::Shared,
110 RulesFormat::DedicatedMarkdown => crate::core::rules_canonical::Wrapper::Dedicated,
111 RulesFormat::CursorMdc => content::cursor_wrapper_for_mdc(&target.path),
115 };
116 let expected = crate::core::rules_canonical::render(shadow, wrapper, level, &profile);
117 (target.name.to_string(), expected)
118 })
119 .collect()
120}
121
122use detect::is_tool_detected;
123use targets::build_rules_targets;
124use write::inject_rules;
125
126struct RulesTarget {
127 name: &'static str,
128 path: PathBuf,
129 format: RulesFormat,
130}
131
132enum RulesFormat {
133 SharedMarkdown,
134 DedicatedMarkdown,
135 CursorMdc,
136}
137
138#[derive(Debug, Default)]
139pub struct InjectResult {
140 pub injected: Vec<String>,
141 pub updated: Vec<String>,
142 pub already: Vec<String>,
143 pub errors: Vec<String>,
144 pub backed_up: Vec<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct RulesTargetStatus {
149 pub name: String,
150 pub detected: bool,
151 pub path: String,
152 pub state: String,
153 pub note: Option<String>,
154}
155
156enum RulesResult {
157 Updated,
158 AlreadyPresent,
159}
160
161pub fn inject_all_rules(home: &std::path::Path) -> InjectResult {
162 inject_rules_where(home, &|_| true)
163}
164
165fn rules_agent_key(target_name: &str) -> Option<&'static str> {
168 Some(match target_name {
169 "Gemini CLI" => "gemini",
170 "VS Code" => "vscode",
171 "Copilot CLI" => "copilot",
172 "Cursor" => "cursor",
173 "Windsurf" => "windsurf",
174 "Zed" => "zed",
175 "Cline" => "cline",
176 "Roo Code" => "roo",
177 "OpenCode" => "opencode",
178 "Continue" => "continue",
179 "Amp" => "amp",
180 "Qwen Code" => "qwen",
181 "Trae" => "trae",
182 "Amazon Q Developer" => "amazonq",
183 "JetBrains IDEs" => "jetbrains",
184 "Antigravity" => "antigravity",
185 "Pi Coding Agent" => "pi",
186 "AWS Kiro" => "kiro",
187 "Verdent" => "verdent",
188 "Crush" => "crush",
189 "Augment" => "augment",
190 "OpenClaw" => "openclaw",
191 "Codex CLI" => "codex",
192 "Grok" => "grok",
193 "Oh My Pi" => "omp",
194 "Hermes Agent" => "hermes",
195 "Qoder" => "qoder",
196 _ => return None,
197 })
198}
199
200#[must_use]
204pub fn planned_rules_files(
205 home: &std::path::Path,
206 allows: &dyn Fn(&str) -> bool,
207) -> Vec<(String, PathBuf)> {
208 let cfg = crate::core::config::Config::load();
209 let injection = cfg.rules_injection_effective();
210 if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project
211 || injection == crate::core::config::RulesInjection::Off
212 {
213 return Vec::new();
214 }
215 let mut files = Vec::new();
216 if injection == crate::core::config::RulesInjection::Shared
217 && allows("claude")
218 && detect::is_named_agent_ready("Claude Code", home)
219 {
220 files.push((
221 "Claude Code".to_string(),
222 crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"),
223 ));
224 }
225 for target in build_rules_targets(home, injection) {
226 if rules_agent_key(target.name).is_some_and(allows) && is_tool_detected(&target, home) {
227 files.push((target.name.to_string(), target.path));
228 }
229 }
230 files
231}
232
233pub fn inject_rules_where(home: &std::path::Path, allows: &dyn Fn(&str) -> bool) -> InjectResult {
235 let cfg = crate::core::config::Config::load();
236 if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
237 return InjectResult::default();
238 }
239 if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
240 return InjectResult::default();
241 }
242
243 let targets = build_rules_targets(home, cfg.rules_injection_effective());
244
245 let mut result = InjectResult::default();
246
247 if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Shared
248 && allows("claude")
249 && detect::is_named_agent_ready("Claude Code", home)
250 {
251 sync_claude_pointer_block(home, &mut result);
252 }
253
254 for target in &targets {
255 if !rules_agent_key(target.name).is_some_and(allows) {
256 continue;
257 }
258 if !is_tool_detected(target, home) {
259 continue;
260 }
261 if rules_agent_key(target.name)
262 .is_some_and(|key| crate::core::governor_hosts::is_installed(key, home))
263 {
264 continue;
265 }
266 if !detect::is_mcp_configured(target, home) {
270 continue;
271 }
272
273 let bak_path = target.path.with_extension(format!(
274 "{}.bak",
275 target
276 .path
277 .extension()
278 .and_then(|e| e.to_str())
279 .unwrap_or("")
280 ));
281 let bak_existed_before = bak_path.exists();
282 let bak_mtime_before = bak_existed_before
283 .then(|| {
284 std::fs::metadata(&bak_path)
285 .ok()
286 .and_then(|m| m.modified().ok())
287 })
288 .flatten();
289
290 match inject_rules(target) {
291 Ok(RulesResult::Updated) => {
292 result.updated.push(target.name.to_string());
293 let bak_is_new = if bak_existed_before {
294 std::fs::metadata(&bak_path)
295 .ok()
296 .and_then(|m| m.modified().ok())
297 != bak_mtime_before
298 } else {
299 bak_path.exists()
300 };
301 if bak_is_new {
302 result
303 .backed_up
304 .push(bak_path.to_string_lossy().to_string());
305 }
306 }
307 Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
308 Err(e) => result.errors.push(format!("{}: {e}", target.name)),
309 }
310 }
311
312 result
313}
314
315pub fn inject_rules_for_agent(home: &std::path::Path, agent_key: &str) -> InjectResult {
317 let cfg = crate::core::config::Config::load();
318 if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
319 return InjectResult::default();
320 }
321 if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
322 return InjectResult::default();
323 }
324
325 if crate::core::governor_hosts::is_installed(agent_key, home) {
327 return InjectResult::default();
328 }
329 let targets = build_rules_targets(home, cfg.rules_injection_effective());
330 let mut result = InjectResult::default();
331
332 if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Shared
333 && is_claude_agent_key(agent_key)
334 {
335 sync_claude_pointer_block(home, &mut result);
336 return result;
337 }
338
339 for target in &targets {
340 if !match_agent_name(agent_key, target.name) {
341 continue;
342 }
343
344 let bak_path = target.path.with_extension(format!(
345 "{}.bak",
346 target
347 .path
348 .extension()
349 .and_then(|e| e.to_str())
350 .unwrap_or("")
351 ));
352 let bak_existed_before = bak_path.exists();
353
354 match inject_rules(target) {
355 Ok(RulesResult::Updated) => {
356 result.updated.push(target.name.to_string());
357 if !bak_existed_before && bak_path.exists() {
358 result
359 .backed_up
360 .push(bak_path.to_string_lossy().to_string());
361 }
362 }
363 Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
364 Err(e) => result.errors.push(format!("{}: {e}", target.name)),
365 }
366 }
367
368 result
369}
370
371fn is_claude_agent_key(agent_key: &str) -> bool {
372 matches!(
373 agent_key.trim().to_ascii_lowercase().as_str(),
374 "claude" | "claude-code" | "claude_code" | "claude code"
375 )
376}
377
378fn sync_claude_pointer_block(home: &std::path::Path, result: &mut InjectResult) {
379 let mode = crate::core::hook_mode::recommend_hook_mode("claude");
380 match crate::core::claude_md::sync_claude_global_rules_block(home, mode) {
381 Ok(true) => result.updated.push("Claude Code".to_string()),
382 Ok(false) => result.already.push("Claude Code".to_string()),
383 Err(error) => result.errors.push(format!("Claude Code: {error}")),
384 }
385}
386
387#[must_use]
390pub fn any_rules_marker_present(home: &std::path::Path) -> bool {
391 use crate::core::config::RulesInjection;
392 let mut seen = std::collections::HashSet::new();
393 for injection in [RulesInjection::Shared, RulesInjection::Dedicated] {
394 for target in build_rules_targets(home, injection) {
395 if !seen.insert(target.path.clone()) {
396 continue;
397 }
398 if let Ok(content) = std::fs::read_to_string(&target.path)
399 && RulesFile::parse(&content).has_content()
400 {
401 return true;
402 }
403 }
404 }
405 false
406}
407
408#[must_use]
414pub fn host_guidance_present(home: &std::path::Path) -> bool {
415 use crate::core::editor_registry::{claude_state_dir, codebuddy_state_dir};
416 any_rules_marker_present(home)
417 || pointer_block_present(&[
418 claude_state_dir(home).join("CLAUDE.md"),
419 codebuddy_state_dir(home).join("CODEBUDDY.md"),
420 ])
421}
422
423fn pointer_block_present(files: &[std::path::PathBuf]) -> bool {
424 files.iter().any(|path| {
425 std::fs::read_to_string(path)
427 .is_ok_and(|c| c.contains(crate::core::rules_canonical::AGENTS_BLOCK_START))
428 })
429}
430
431fn match_agent_name(cli_key: &str, target_name: &str) -> bool {
432 let needle = cli_key.to_lowercase();
433 let tn = target_name.to_lowercase();
434 if needle == "pi" {
435 return tn.contains("pi coding");
436 }
437 if needle == "omp" {
438 return tn.contains("oh my pi");
439 }
440 needle.contains(&tn)
441 || tn.contains(&needle)
442 || (needle.contains("cursor") && tn.contains("cursor"))
443 || (needle.contains("claude") && tn.contains("claude"))
444 || (needle.contains("codebuddy") && tn.contains("codebuddy"))
445 || (needle.contains("windsurf") && tn.contains("windsurf"))
446 || (needle.contains("codex") && tn.contains("claude"))
447 || (needle.contains("zed") && tn.contains("zed"))
448 || (needle.contains("copilot") && tn.contains("copilot"))
449 || (needle.contains("jetbrains") && tn.contains("jetbrains"))
450 || (needle.contains("kiro") && tn.contains("kiro"))
451 || (needle.contains("gemini") && tn.contains("gemini"))
452 || (needle == "opencode" && tn.contains("opencode"))
453 || (needle == "cline" && tn.contains("cline"))
454 || (needle == "roo" && tn.contains("roo"))
455 || (needle == "amp" && tn.contains("amp"))
456 || (needle == "trae" && tn.contains("trae"))
457 || (needle == "amazonq" && tn.contains("amazon"))
458 || (needle == "crush" && tn.contains("crush"))
459 || (needle == "verdent" && tn.contains("verdent"))
460 || (needle == "continue" && tn.contains("continue"))
461 || (needle == "qwen" && tn.contains("qwen"))
462 || (needle == "antigravity" && tn.contains("antigravity"))
463 || (needle == "augment" && tn.contains("augment"))
464 || (needle == "openclaw" && tn.contains("openclaw"))
465 || (needle == "grok" && tn.contains("grok"))
466 || (needle == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
467}
468
469pub fn check_rules_freshness(client_name: &str) -> Option<String> {
471 let home = dirs::home_dir()?;
472 check_rules_freshness_at_home(client_name, &home)
473}
474
475fn check_rules_freshness_at_home(client_name: &str, home: &std::path::Path) -> Option<String> {
476 let cfg = crate::core::config::Config::load();
477 if cfg.declines_rule_steering()
478 || cfg.rules_scope_effective() == crate::core::config::RulesScope::Project
479 {
480 return None;
481 }
482 let injection = cfg.rules_injection_effective();
483 let targets = build_rules_targets(home, injection);
484
485 let matched: Vec<&RulesTarget> = targets
486 .iter()
487 .filter(|t| match_agent_name(client_name, t.name))
488 .collect();
489
490 if matched.is_empty() {
491 return None;
492 }
493
494 for target in &matched {
495 if !is_tool_detected(target, home) || !detect::is_mcp_configured(target, home) {
498 continue;
499 }
500 if !target.path.exists() {
501 continue;
502 }
503 let content = std::fs::read_to_string(&target.path).ok()?;
504 let file = RulesFile::parse(&content);
505 if file.has_content() && !file.is_current() {
506 return Some(format!(
507 "[RULES OUTDATED] Your {} rules were written by an older lean-ctx version. \
508 Re-read your rules file ({}) or run `lean-ctx setup` to update, \
509 then start a new session for full compatibility.",
510 target.name,
511 target.path.display()
512 ));
513 }
514 }
515
516 None
517}
518
519pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
520 let injection = crate::core::config::Config::load().rules_injection_effective();
521 let targets = build_rules_targets(home, injection);
522 let mut out = Vec::new();
523
524 for target in &targets {
525 let detected = is_tool_detected(target, home);
526 let path = target.path.to_string_lossy().to_string();
527
528 let state = if !detected {
529 "not_detected".to_string()
530 } else if !target.path.exists() {
531 "missing".to_string()
532 } else {
533 match std::fs::read_to_string(&target.path) {
534 Ok(content) => {
535 let file = RulesFile::parse(&content);
536 if file.has_content() {
537 if file.is_current() {
538 "up_to_date".to_string()
539 } else {
540 "outdated".to_string()
541 }
542 } else {
543 "present_without_marker".to_string()
544 }
545 }
546 Err(_) => "read_error".to_string(),
547 }
548 };
549
550 out.push(RulesTargetStatus {
551 name: target.name.to_string(),
552 detected,
553 path,
554 state,
555 note: None,
556 });
557 }
558
559 out
560}