1use std::path::{Path, PathBuf};
21
22const COMPRESSION_START: &str = "<!-- lean-ctx-compression -->";
23const COMPRESSION_END: &str = "<!-- /lean-ctx-compression -->";
24const BLOCK_START: &str = "<!-- lean-ctx -->";
25const BLOCK_END: &str = "<!-- /lean-ctx -->";
26
27#[derive(Debug, PartialEq, Eq)]
29pub(crate) enum Action {
30 DeleteFile { path: PathBuf, reason: String },
32 StripBlocks { path: PathBuf, reason: String },
34 Report { path: PathBuf, note: String },
36}
37
38fn is_owned_rules_file(content: &str) -> bool {
42 let starts_with_header = content
43 .trim_start()
44 .starts_with(crate::rules_inject::RULES_MARKER)
45 || (content.trim_start().starts_with("---")
47 && content.contains(crate::rules_inject::RULES_MARKER));
48 starts_with_header && content.contains("<!-- lean-ctx-rules-")
49}
50
51fn has_marked_block(content: &str) -> bool {
52 (content.contains(BLOCK_START) && content.contains(BLOCK_END))
53 || (content.contains(COMPRESSION_START) && content.contains(COMPRESSION_END))
54}
55
56pub(crate) fn strip_lean_ctx_blocks(content: &str) -> String {
58 let mut out = content.to_string();
59 loop {
62 let next = if out.contains(BLOCK_START) && out.contains(BLOCK_END) {
63 crate::marked_block::remove_content(&out, BLOCK_START, BLOCK_END)
64 } else if out.contains(COMPRESSION_START) && out.contains(COMPRESSION_END) {
65 crate::marked_block::remove_content(&out, COMPRESSION_START, COMPRESSION_END)
66 } else {
67 break;
68 };
69 if next == out {
70 break;
71 }
72 out = next;
73 }
74 let trimmed = out.trim_end();
75 if trimmed.is_empty() {
76 String::new()
77 } else {
78 format!("{trimmed}\n")
79 }
80}
81
82fn project_owned_candidates(dir: &Path) -> Vec<PathBuf> {
85 vec![
86 dir.join(".cursor/rules/lean-ctx.mdc"),
87 dir.join(".claude/rules/lean-ctx.md"),
88 dir.join(".codebuddy/rules/lean-ctx.md"),
89 dir.join(".windsurf/rules/lean-ctx.md"),
90 dir.join(".cline/rules/lean-ctx.md"),
91 dir.join(".roo/rules/lean-ctx.md"),
92 ]
93}
94
95pub(crate) fn plan(home: &Path, project: &Path) -> Vec<Action> {
97 let mut actions = Vec::new();
98 let canonical_cursor_mdc = home.join(".cursor/rules/lean-ctx.mdc");
99
100 let mut dir = Some(project.to_path_buf());
102 while let Some(d) = dir {
103 if d == *home {
104 break;
105 }
106 for candidate in project_owned_candidates(&d) {
107 if candidate == canonical_cursor_mdc {
108 continue;
109 }
110 let Ok(content) = std::fs::read_to_string(&candidate) else {
111 continue;
112 };
113 if is_owned_rules_file(&content) {
114 actions.push(Action::DeleteFile {
115 path: candidate,
116 reason: "lean-ctx-owned duplicate of the global rules file".into(),
117 });
118 } else if !content.trim().is_empty() {
119 actions.push(Action::Report {
120 path: candidate,
121 note: "contains custom edits — not lean-ctx-owned, left untouched".into(),
122 });
123 }
124 }
125 dir = d.parent().map(Path::to_path_buf);
126 }
127
128 let cursorrules = project.join(".cursorrules");
131 if let Ok(content) = std::fs::read_to_string(&cursorrules) {
132 if canonical_cursor_mdc.exists() && has_marked_block(&content) {
133 actions.push(Action::StripBlocks {
134 path: cursorrules,
135 reason: "global ~/.cursor/rules/lean-ctx.mdc already carries these blocks".into(),
136 });
137 } else if !canonical_cursor_mdc.exists() && content.contains("lean-ctx") {
138 actions.push(Action::Report {
139 path: cursorrules,
140 note: "no global Cursor mdc found — .cursorrules stays the carrier".into(),
141 });
142 } else if content.contains("lean-ctx") && !has_marked_block(&content) {
143 actions.push(Action::Report {
144 path: cursorrules,
145 note: "mentions lean-ctx without markers (manual rules) — review by hand".into(),
146 });
147 }
148 }
149
150 actions
151}
152
153fn apply(action: &Action) -> String {
155 match action {
156 Action::DeleteFile { path, .. } => match std::fs::remove_file(path) {
157 Ok(()) => format!("deleted {}", path.display()),
158 Err(e) => format!("FAILED {} ({e})", path.display()),
159 },
160 Action::StripBlocks { path, .. } => {
161 let Ok(content) = std::fs::read_to_string(path) else {
162 return format!("FAILED {} (unreadable)", path.display());
163 };
164 let stripped = strip_lean_ctx_blocks(&content);
165 if stripped == content {
166 return format!("unchanged {}", path.display());
167 }
168 let bak = path.with_extension("bak");
169 if let Err(e) = std::fs::write(&bak, &content) {
170 return format!("FAILED {} (backup: {e})", path.display());
171 }
172 if stripped.is_empty() {
173 match std::fs::remove_file(path) {
174 Ok(()) => format!(
175 "deleted {} (only lean-ctx blocks, backup: {})",
176 path.display(),
177 bak.display()
178 ),
179 Err(e) => format!("FAILED {} ({e})", path.display()),
180 }
181 } else {
182 match std::fs::write(path, &stripped) {
183 Ok(()) => format!("stripped {} (backup: {})", path.display(), bak.display()),
184 Err(e) => format!("FAILED {} ({e})", path.display()),
185 }
186 }
187 }
188 Action::Report { path, note } => format!("info {} — {note}", path.display()),
189 }
190}
191
192pub fn run(apply_changes: bool) -> i32 {
194 let Some(home) = dirs::home_dir() else {
195 eprintln!("Error: could not determine home directory");
196 return 1;
197 };
198 let project = std::env::current_dir().unwrap_or_else(|_| home.clone());
199 let actions = plan(&home, &project);
200
201 if actions.is_empty() {
202 println!("No duplicated lean-ctx rules found — every client pays once.");
203 return 0;
204 }
205
206 println!(
207 "{} (project: {})\n",
208 if apply_changes {
209 "Deduplicating lean-ctx rules"
210 } else {
211 "Dedup plan (dry-run — pass --apply to execute)"
212 },
213 project.display()
214 );
215
216 let mut fixable = 0usize;
217 for action in &actions {
218 match action {
219 Action::DeleteFile { path, reason } => {
220 fixable += 1;
221 if apply_changes {
222 println!(" {}", apply(action));
223 } else {
224 println!(" delete {}\n ({reason})", path.display());
225 }
226 }
227 Action::StripBlocks { path, reason } => {
228 fixable += 1;
229 if apply_changes {
230 println!(" {}", apply(action));
231 } else {
232 println!(" strip {}\n ({reason})", path.display());
233 }
234 }
235 Action::Report { .. } => println!(" {}", apply(action)),
236 }
237 }
238
239 if !apply_changes && fixable > 0 {
240 println!("\nRun `lean-ctx rules dedup --apply` to fix {fixable} duplicate(s).");
241 }
242 if apply_changes && fixable > 0 {
243 println!("\nDone. Verify with `lean-ctx doctor overhead`.");
244 }
245 0
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 fn owned_mdc() -> String {
253 format!(
254 "---\ndescription: lean-ctx\n---\n{}\n<!-- lean-ctx-rules-v9 -->\nbody\n",
255 crate::rules_inject::RULES_MARKER
256 )
257 }
258
259 #[test]
260 fn detects_owned_dedicated_files() {
261 assert!(is_owned_rules_file(&owned_mdc()));
262 assert!(is_owned_rules_file(&format!(
263 "{}\n<!-- lean-ctx-rules-v11 -->\nbody\n",
264 crate::rules_inject::RULES_MARKER
265 )));
266 assert!(!is_owned_rules_file("# My rules\nuse lean-ctx tools\n"));
268 assert!(!is_owned_rules_file(&format!(
270 "# my header\n{}\n<!-- lean-ctx-rules-v11 -->\n",
271 crate::rules_inject::RULES_MARKER
272 )));
273 }
274
275 #[test]
276 fn strip_removes_rules_and_compression_blocks() {
277 let content = "user line\n<!-- lean-ctx -->\nour rules\n<!-- /lean-ctx -->\nmore user\n<!-- lean-ctx-compression -->\nstyle\n<!-- /lean-ctx-compression -->\n";
278 let out = strip_lean_ctx_blocks(content);
279 assert!(out.contains("user line"));
280 assert!(out.contains("more user"));
281 assert!(!out.contains("our rules"));
282 assert!(!out.contains("style"));
283 assert!(!out.contains("lean-ctx-compression"));
284 }
285
286 #[test]
287 fn strip_of_pure_block_file_yields_empty() {
288 let content = "<!-- lean-ctx -->\nonly ours\n<!-- /lean-ctx -->\n";
289 assert_eq!(strip_lean_ctx_blocks(content), "");
290 }
291
292 #[test]
293 fn plan_deletes_project_and_parent_owned_files_only() {
294 let tmp = tempfile::tempdir().unwrap();
295 let home = tmp.path();
296 let parent = home.join("projects");
297 let project = parent.join("app");
298
299 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
301 std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
302 std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
304 std::fs::write(project.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
305 std::fs::create_dir_all(parent.join(".cursor/rules")).unwrap();
306 std::fs::write(parent.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
307 std::fs::create_dir_all(project.join(".claude/rules")).unwrap();
309 std::fs::write(
310 project.join(".claude/rules/lean-ctx.md"),
311 "# customized by user\nkeep me\n",
312 )
313 .unwrap();
314
315 let actions = plan(home, &project);
316 let deletes: Vec<&PathBuf> = actions
317 .iter()
318 .filter_map(|a| match a {
319 Action::DeleteFile { path, .. } => Some(path),
320 _ => None,
321 })
322 .collect();
323 assert_eq!(deletes.len(), 2, "project + parent copies: {actions:?}");
324 assert!(deletes.iter().all(|p| !p.starts_with(home.join(".cursor"))));
325 assert!(actions.iter().any(|a| matches!(
326 a,
327 Action::Report { path, .. } if path.ends_with(".claude/rules/lean-ctx.md")
328 )));
329 }
330
331 #[test]
332 fn plan_strips_cursorrules_only_with_canonical_mdc() {
333 let tmp = tempfile::tempdir().unwrap();
334 let home = tmp.path();
335 let project = home.join("app");
336 std::fs::create_dir_all(&project).unwrap();
337 let rules = "<!-- lean-ctx -->\npointer\n<!-- /lean-ctx -->\n";
338 std::fs::write(project.join(".cursorrules"), rules).unwrap();
339
340 let actions = plan(home, &project);
342 assert!(
343 !actions
344 .iter()
345 .any(|a| matches!(a, Action::StripBlocks { .. })),
346 "{actions:?}"
347 );
348
349 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
351 std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
352 let actions = plan(home, &project);
353 assert!(
354 actions.iter().any(|a| matches!(
355 a,
356 Action::StripBlocks { path, .. } if path.ends_with(".cursorrules")
357 )),
358 "{actions:?}"
359 );
360 }
361
362 #[test]
363 fn apply_strip_writes_backup_and_keeps_user_content() {
364 let tmp = tempfile::tempdir().unwrap();
365 let path = tmp.path().join(".cursorrules");
366 std::fs::write(
367 &path,
368 "my custom rule\n<!-- lean-ctx -->\nours\n<!-- /lean-ctx -->\n",
369 )
370 .unwrap();
371
372 let msg = apply(&Action::StripBlocks {
373 path: path.clone(),
374 reason: String::new(),
375 });
376 assert!(msg.starts_with("stripped"), "{msg}");
377 let after = std::fs::read_to_string(&path).unwrap();
378 assert_eq!(after, "my custom rule\n");
379 let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
380 assert!(bak.contains("ours"));
381 }
382
383 #[test]
384 fn apply_strip_deletes_file_that_was_only_ours() {
385 let tmp = tempfile::tempdir().unwrap();
386 let path = tmp.path().join(".cursorrules");
387 std::fs::write(&path, "<!-- lean-ctx -->\nours\n<!-- /lean-ctx -->\n").unwrap();
388
389 let msg = apply(&Action::StripBlocks {
390 path: path.clone(),
391 reason: String::new(),
392 });
393 assert!(msg.starts_with("deleted"), "{msg}");
394 assert!(!path.exists());
395 assert!(path.with_extension("bak").exists());
396 }
397}