use std::path::{Path, PathBuf};
use crate::core::rules_canonical::{END_MARK as BLOCK_END, START_MARK as BLOCK_START};
use crate::core::rules_channel::{COMPRESSION_BLOCK_END, COMPRESSION_BLOCK_START};
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Action {
DeleteFile { path: PathBuf, reason: String },
StripBlocks { path: PathBuf, reason: String },
StripCompression { path: PathBuf, reason: String },
Report { path: PathBuf, note: String },
}
fn is_owned_rules_file(content: &str) -> bool {
let starts_with_header = content
.trim_start()
.starts_with(crate::core::rules_canonical::START_MARK)
|| (content.trim_start().starts_with("---")
&& content.contains(crate::core::rules_canonical::START_MARK));
starts_with_header && content.contains("<!-- version:")
}
fn has_marked_block(content: &str) -> bool {
(content.contains(BLOCK_START) && content.contains(BLOCK_END))
|| (content.contains(COMPRESSION_BLOCK_START) && content.contains(COMPRESSION_BLOCK_END))
}
pub(crate) fn strip_lean_ctx_blocks(content: &str) -> String {
let mut out = content.to_string();
loop {
let next = if out.contains(BLOCK_START) && out.contains(BLOCK_END) {
crate::marked_block::remove_content(&out, BLOCK_START, BLOCK_END)
} else if out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END) {
crate::marked_block::remove_content(
&out,
COMPRESSION_BLOCK_START,
COMPRESSION_BLOCK_END,
)
} else {
break;
};
if next == out {
break;
}
out = next;
}
let trimmed = out.trim_end();
if trimmed.is_empty() {
String::new()
} else {
format!("{trimmed}\n")
}
}
pub(crate) fn strip_compression_block(content: &str) -> String {
if !(content.contains(COMPRESSION_BLOCK_START) && content.contains(COMPRESSION_BLOCK_END)) {
return content.to_string();
}
let stripped = crate::marked_block::remove_content(
content,
COMPRESSION_BLOCK_START,
COMPRESSION_BLOCK_END,
);
let trimmed = stripped.trim_end();
if trimmed.is_empty() {
String::new()
} else {
format!("{trimmed}\n")
}
}
fn project_owned_candidates(dir: &Path) -> Vec<PathBuf> {
vec![
dir.join(".cursor/rules/lean-ctx.mdc"),
dir.join(".claude/rules/lean-ctx.md"),
dir.join(".codebuddy/rules/lean-ctx.md"),
dir.join(".windsurf/rules/lean-ctx.md"),
dir.join(".cline/rules/lean-ctx.md"),
dir.join(".roo/rules/lean-ctx.md"),
]
}
pub(crate) fn plan(home: &Path, project: &Path) -> Vec<Action> {
let mut actions = Vec::new();
let canonical_cursor_mdc = home.join(".cursor/rules/lean-ctx.mdc");
let mut dir = Some(project.to_path_buf());
while let Some(d) = dir {
if d == *home {
break;
}
for candidate in project_owned_candidates(&d) {
if candidate == canonical_cursor_mdc {
continue;
}
let Ok(content) = std::fs::read_to_string(&candidate) else {
continue;
};
if is_owned_rules_file(&content) {
actions.push(Action::DeleteFile {
path: candidate,
reason: "lean-ctx-owned duplicate of the global rules file".into(),
});
} else if !content.trim().is_empty() {
actions.push(Action::Report {
path: candidate,
note: "contains custom edits — not lean-ctx-owned, left untouched".into(),
});
}
}
dir = d.parent().map(Path::to_path_buf);
}
let cursorrules = project.join(".cursorrules");
if let Ok(content) = std::fs::read_to_string(&cursorrules) {
if canonical_cursor_mdc.exists() && has_marked_block(&content) {
actions.push(Action::StripBlocks {
path: cursorrules,
reason: "global ~/.cursor/rules/lean-ctx.mdc already carries these blocks".into(),
});
} else if !canonical_cursor_mdc.exists() && content.contains("lean-ctx") {
actions.push(Action::Report {
path: cursorrules,
note: "no global Cursor mdc found — .cursorrules stays the carrier".into(),
});
} else if content.contains("lean-ctx") && !has_marked_block(&content) {
actions.push(Action::Report {
path: cursorrules,
note: "mentions lean-ctx without markers (manual rules) — review by hand".into(),
});
}
}
let agents_md = project.join("AGENTS.md");
if let Ok(content) = std::fs::read_to_string(&agents_md) {
let has_compression =
content.contains(COMPRESSION_BLOCK_START) && content.contains(COMPRESSION_BLOCK_END);
if has_compression {
if crate::core::rules_channel::agents_md_can_thin(home) {
actions.push(Action::StripCompression {
path: agents_md,
reason: "every AGENTS.md reader already loads the compression block from its own canonical file".into(),
});
} else {
actions.push(Action::Report {
path: agents_md,
note: "an AGENTS.md reader has no other compression source — AGENTS.md stays the carrier".into(),
});
}
}
}
actions
}
fn apply(action: &Action) -> String {
match action {
Action::DeleteFile { path, .. } => match std::fs::remove_file(path) {
Ok(()) => format!("deleted {}", path.display()),
Err(e) => format!("FAILED {} ({e})", path.display()),
},
Action::StripBlocks { path, .. } => {
let Ok(content) = std::fs::read_to_string(path) else {
return format!("FAILED {} (unreadable)", path.display());
};
let stripped = strip_lean_ctx_blocks(&content);
if stripped == content {
return format!("unchanged {}", path.display());
}
let bak = path.with_extension("bak");
if let Err(e) = std::fs::write(&bak, &content) {
return format!("FAILED {} (backup: {e})", path.display());
}
if stripped.is_empty() {
match std::fs::remove_file(path) {
Ok(()) => format!(
"deleted {} (only lean-ctx blocks, backup: {})",
path.display(),
bak.display()
),
Err(e) => format!("FAILED {} ({e})", path.display()),
}
} else {
match std::fs::write(path, &stripped) {
Ok(()) => format!("stripped {} (backup: {})", path.display(), bak.display()),
Err(e) => format!("FAILED {} ({e})", path.display()),
}
}
}
Action::StripCompression { path, .. } => {
let Ok(content) = std::fs::read_to_string(path) else {
return format!("FAILED {} (unreadable)", path.display());
};
let stripped = strip_compression_block(&content);
if stripped == content {
return format!("unchanged {}", path.display());
}
let bak = path.with_extension("bak");
if let Err(e) = std::fs::write(&bak, &content) {
return format!("FAILED {} (backup: {e})", path.display());
}
match std::fs::write(path, &stripped) {
Ok(()) => format!("thinned {} (backup: {})", path.display(), bak.display()),
Err(e) => format!("FAILED {} ({e})", path.display()),
}
}
Action::Report { path, note } => format!("info {} — {note}", path.display()),
}
}
pub fn run(apply_changes: bool) -> i32 {
let Some(home) = dirs::home_dir() else {
eprintln!("Error: could not determine home directory");
return 1;
};
let project = std::env::current_dir().unwrap_or_else(|_| home.clone());
let actions = plan(&home, &project);
if actions.is_empty() {
println!("No duplicated lean-ctx rules found — every client pays once.");
return 0;
}
println!(
"{} (project: {})\n",
if apply_changes {
"Deduplicating lean-ctx rules"
} else {
"Dedup plan (dry-run — pass --apply to execute)"
},
project.display()
);
let mut fixable = 0usize;
for action in &actions {
match action {
Action::DeleteFile { path, reason } => {
fixable += 1;
if apply_changes {
println!(" {}", apply(action));
} else {
println!(" delete {}\n ({reason})", path.display());
}
}
Action::StripBlocks { path, reason } => {
fixable += 1;
if apply_changes {
println!(" {}", apply(action));
} else {
println!(" strip {}\n ({reason})", path.display());
}
}
Action::StripCompression { path, reason } => {
fixable += 1;
if apply_changes {
println!(" {}", apply(action));
} else {
println!(" thin {}\n ({reason})", path.display());
}
}
Action::Report { .. } => println!(" {}", apply(action)),
}
}
if !apply_changes && fixable > 0 {
println!("\nRun `lean-ctx rules dedup --apply` to fix {fixable} duplicate(s).");
}
if apply_changes && fixable > 0 {
println!("\nDone. Verify with `lean-ctx doctor overhead`.");
}
0
}
#[cfg(test)]
mod tests {
use super::*;
fn owned_mdc() -> String {
format!(
"---\ndescription: lean-ctx\n---\n{}\n<!-- version: 1 -->\nbody\n",
crate::core::rules_canonical::START_MARK
)
}
#[test]
fn detects_owned_dedicated_files() {
assert!(is_owned_rules_file(&owned_mdc()));
assert!(is_owned_rules_file(&format!(
"{}\n<!-- version: 1 -->\nbody\n",
crate::core::rules_canonical::START_MARK
)));
assert!(!is_owned_rules_file("# My rules\nuse lean-ctx tools\n"));
assert!(!is_owned_rules_file(&format!(
"# my header\n{}\n<!-- version: 1 -->\n",
crate::core::rules_canonical::START_MARK
)));
}
#[test]
fn strip_removes_rules_and_compression_blocks() {
let content = format!(
"user line\n{BLOCK_START}\nour rules\n{BLOCK_END}\nmore user\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n",
);
let out = strip_lean_ctx_blocks(&content);
assert!(out.contains("user line"));
assert!(out.contains("more user"));
assert!(!out.contains("our rules"));
assert!(!out.contains("style"));
assert!(!out.contains("lean-ctx-compression"));
}
#[test]
fn strip_of_pure_block_file_yields_empty() {
let content = format!("{BLOCK_START}\nonly ours\n{BLOCK_END}\n");
assert_eq!(strip_lean_ctx_blocks(&content), "");
}
#[test]
fn plan_deletes_project_and_parent_owned_files_only() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let parent = home.join("projects");
let project = parent.join("app");
std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
std::fs::write(project.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
std::fs::create_dir_all(parent.join(".cursor/rules")).unwrap();
std::fs::write(parent.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
std::fs::create_dir_all(project.join(".claude/rules")).unwrap();
std::fs::write(
project.join(".claude/rules/lean-ctx.md"),
"# customized by user\nkeep me\n",
)
.unwrap();
let actions = plan(home, &project);
let deletes: Vec<&PathBuf> = actions
.iter()
.filter_map(|a| match a {
Action::DeleteFile { path, .. } => Some(path),
_ => None,
})
.collect();
assert_eq!(deletes.len(), 2, "project + parent copies: {actions:?}");
assert!(deletes.iter().all(|p| !p.starts_with(home.join(".cursor"))));
assert!(actions.iter().any(|a| matches!(
a,
Action::Report { path, .. } if path.ends_with(".claude/rules/lean-ctx.md")
)));
}
#[test]
fn plan_strips_cursorrules_only_with_canonical_mdc() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let project = home.join("app");
std::fs::create_dir_all(&project).unwrap();
let rules = format!("{BLOCK_START}\npointer\n{BLOCK_END}\n");
std::fs::write(project.join(".cursorrules"), rules).unwrap();
let actions = plan(home, &project);
assert!(
!actions
.iter()
.any(|a| matches!(a, Action::StripBlocks { .. })),
"{actions:?}"
);
std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
let actions = plan(home, &project);
assert!(
actions.iter().any(|a| matches!(
a,
Action::StripBlocks { path, .. } if path.ends_with(".cursorrules")
)),
"{actions:?}"
);
}
#[test]
fn apply_strip_writes_backup_and_keeps_user_content() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".cursorrules");
std::fs::write(
&path,
format!("my custom rule\n{BLOCK_START}\nours\n{BLOCK_END}\n"),
)
.unwrap();
let msg = apply(&Action::StripBlocks {
path: path.clone(),
reason: String::new(),
});
assert!(msg.starts_with("stripped"), "{msg}");
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(after, "my custom rule\n");
let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
assert!(bak.contains("ours"));
}
#[test]
fn strip_compression_keeps_pointer_and_user_content() {
let content = format!(
"# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nOUTPUT STYLE\n{COMPRESSION_BLOCK_END}\n",
);
let out = strip_compression_block(&content);
assert!(out.contains("# Agent Instructions"));
assert!(out.contains(BLOCK_START));
assert!(out.contains("pointer"));
assert!(!out.contains("lean-ctx-compression"));
assert!(!out.contains("OUTPUT STYLE"));
}
#[test]
fn plan_thins_agents_md_when_cursor_covered_and_no_codex() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let project = home.join("app");
std::fs::create_dir_all(&project).unwrap();
crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
std::fs::write(
home.join(".cursor/rules/lean-ctx.mdc"),
format!(
"{}\n<!-- version: 1 -->\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n",
crate::core::rules_canonical::START_MARK
),
)
.unwrap();
std::fs::write(
project.join("AGENTS.md"),
format!(
"# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
),
)
.unwrap();
let actions = plan(home, &project);
assert!(
actions.iter().any(|a| matches!(
a,
Action::StripCompression { path, .. } if path.ends_with("AGENTS.md")
)),
"{actions:?}"
);
crate::test_env::remove_var("CODEX_HOME");
}
#[test]
fn plan_keeps_agents_md_carrier_when_cursor_uncovered() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let project = home.join("app");
std::fs::create_dir_all(&project).unwrap();
crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
std::fs::write(
project.join("AGENTS.md"),
format!(
"# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
),
)
.unwrap();
let actions = plan(home, &project);
assert!(
!actions
.iter()
.any(|a| matches!(a, Action::StripCompression { .. })),
"{actions:?}"
);
assert!(
actions.iter().any(|a| matches!(
a,
Action::Report { path, .. } if path.ends_with("AGENTS.md")
)),
"{actions:?}"
);
crate::test_env::remove_var("CODEX_HOME");
}
#[test]
fn apply_strip_compression_thins_and_backs_up() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("AGENTS.md");
std::fs::write(
&path,
format!(
"# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
),
)
.unwrap();
let msg = apply(&Action::StripCompression {
path: path.clone(),
reason: String::new(),
});
assert!(msg.starts_with("thinned"), "{msg}");
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains(BLOCK_START));
assert!(!after.contains("lean-ctx-compression"));
let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
assert!(bak.contains("lean-ctx-compression"));
}
#[test]
fn apply_strip_deletes_file_that_was_only_ours() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".cursorrules");
std::fs::write(&path, format!("{BLOCK_START}\nours\n{BLOCK_END}\n")).unwrap();
let msg = apply(&Action::StripBlocks {
path: path.clone(),
reason: String::new(),
});
assert!(msg.starts_with("deleted"), "{msg}");
assert!(!path.exists());
assert!(path.with_extension("bak").exists());
}
}