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