dump-shitty-claude-md 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/scan.rs ADDED
@@ -0,0 +1,501 @@
1
+ use anyhow::Result;
2
+ use ignore::gitignore::{Gitignore, GitignoreBuilder};
3
+ use ignore::{WalkBuilder, WalkState};
4
+ use std::collections::{BTreeSet, HashMap};
5
+ use std::ffi::OsStr;
6
+ use std::path::{Path, PathBuf};
7
+ use std::sync::Mutex;
8
+
9
+ /// A vendor-specific instruction file the tool can consolidate into AGENTS.md.
10
+ /// `Claude` is always migrated; every other format is opt-in.
11
+ #[derive(
12
+ Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, clap::ValueEnum, serde::Serialize,
13
+ )]
14
+ #[serde(rename_all = "kebab-case")]
15
+ #[value(rename_all = "kebab-case")]
16
+ pub enum Format {
17
+ /// CLAUDE.md — Claude Code (AGENTS.md fallback ≥2.1.277)
18
+ Claude,
19
+ /// .cursorrules — deprecated Cursor legacy file
20
+ Cursor,
21
+ /// .windsurfrules — Windsurf legacy file
22
+ Windsurf,
23
+ /// .clinerules — Cline single-file rules
24
+ Cline,
25
+ /// .github/copilot-instructions.md — GitHub Copilot repo-wide
26
+ Copilot,
27
+ /// GEMINI.md — Gemini CLI (migrated only when context.fileName allows AGENTS.md)
28
+ Gemini,
29
+ /// .rules — Zed legacy rules file
30
+ Zed,
31
+ /// CONVENTIONS.md / conventions.md — Aider
32
+ Aider,
33
+ /// .roorules — Roo Code legacy file
34
+ Roo,
35
+ /// .kilocoderules — Kilo Code legacy file
36
+ Kilo,
37
+ /// .goosehints — Goose (Block) hints file
38
+ Goose,
39
+ /// WARP.md — Warp terminal agent
40
+ Warp,
41
+ }
42
+
43
+ impl Format {
44
+ /// Every opt-in format — everything except `Claude`, which is always in
45
+ /// scope. Drives `--all-formats` and the interactive picker.
46
+ pub const OPT_IN: &'static [Format] = &[
47
+ Format::Cursor,
48
+ Format::Windsurf,
49
+ Format::Cline,
50
+ Format::Copilot,
51
+ Format::Gemini,
52
+ Format::Zed,
53
+ Format::Aider,
54
+ Format::Roo,
55
+ Format::Kilo,
56
+ Format::Goose,
57
+ Format::Warp,
58
+ ];
59
+
60
+ /// Display name used in reports and merge markers.
61
+ pub fn label(self) -> &'static str {
62
+ match self {
63
+ Format::Claude => "CLAUDE.md",
64
+ Format::Cursor => ".cursorrules",
65
+ Format::Windsurf => ".windsurfrules",
66
+ Format::Cline => ".clinerules",
67
+ Format::Copilot => ".github/copilot-instructions.md",
68
+ Format::Gemini => "GEMINI.md",
69
+ Format::Zed => ".rules",
70
+ Format::Aider => "CONVENTIONS.md",
71
+ Format::Roo => ".roorules",
72
+ Format::Kilo => ".kilocoderules",
73
+ Format::Goose => ".goosehints",
74
+ Format::Warp => "WARP.md",
75
+ }
76
+ }
77
+ }
78
+
79
+ /// Map a file path to its instruction format, if it is one we handle.
80
+ /// `copilot-instructions.md` only counts inside a `.github` directory.
81
+ fn format_for(path: &Path) -> Option<Format> {
82
+ let name = path.file_name()?.to_str()?;
83
+ match name {
84
+ "CLAUDE.md" => Some(Format::Claude),
85
+ ".cursorrules" => Some(Format::Cursor),
86
+ ".windsurfrules" => Some(Format::Windsurf),
87
+ ".clinerules" => Some(Format::Cline),
88
+ ".roorules" => Some(Format::Roo),
89
+ ".kilocoderules" => Some(Format::Kilo),
90
+ ".goosehints" => Some(Format::Goose),
91
+ "WARP.md" => Some(Format::Warp),
92
+ ".rules" => Some(Format::Zed),
93
+ "GEMINI.md" => Some(Format::Gemini),
94
+ // Aider's documented name is uppercase; a lowercase conventions.md is
95
+ // usually a generic doc, not an agent file — don't touch it.
96
+ "CONVENTIONS.md" => Some(Format::Aider),
97
+ "copilot-instructions.md" => path
98
+ .parent()
99
+ .and_then(|p| p.file_name())
100
+ .and_then(|n| n.to_str())
101
+ .filter(|n| *n == ".github")
102
+ .map(|_| Format::Copilot),
103
+ _ => None,
104
+ }
105
+ }
106
+
107
+ /// An instruction file found during the walk, classified by repo relationship.
108
+ #[derive(Debug)]
109
+ pub struct FoundFile {
110
+ pub path: PathBuf,
111
+ /// Nearest ancestor directory containing `.git` (dir or file).
112
+ /// `None` → stray file outside any repo.
113
+ pub repo_root: Option<PathBuf>,
114
+ pub format: Format,
115
+ }
116
+
117
+ impl FoundFile {
118
+ /// True when the file sits at its format's canonical position at the repo
119
+ /// root (`.github/copilot-instructions.md` counts as root-level).
120
+ pub fn is_repo_root_file(&self) -> bool {
121
+ let Some(root) = self.repo_root.as_deref() else {
122
+ return false;
123
+ };
124
+ match self.format {
125
+ Format::Copilot => self.path == root.join(".github/copilot-instructions.md"),
126
+ _ => self.path.parent() == Some(root),
127
+ }
128
+ }
129
+
130
+ /// Path relative to the repo root, e.g. `CLAUDE.md` or
131
+ /// `.github/copilot-instructions.md`.
132
+ pub fn rel(&self) -> PathBuf {
133
+ self.repo_root
134
+ .as_deref()
135
+ .and_then(|r| self.path.strip_prefix(r).ok())
136
+ .unwrap_or(&self.path)
137
+ .to_path_buf()
138
+ }
139
+ }
140
+
141
+ /// Directories never descended into (matched on dir name).
142
+ const PRUNE_NAMES: &[&str] = &[
143
+ ".git",
144
+ "node_modules",
145
+ "Library",
146
+ ".Trash",
147
+ ".cache",
148
+ ".cargo",
149
+ ".rustup",
150
+ ".npm",
151
+ ".bun",
152
+ ".nvm",
153
+ ".pyenv",
154
+ ".rbenv",
155
+ ".volta",
156
+ ".deno",
157
+ ".gradle",
158
+ ".m2",
159
+ ".ollama",
160
+ "miniconda3",
161
+ "anaconda3",
162
+ ".conda",
163
+ "OrbStack",
164
+ // Vendored dependencies + build output across ecosystems: a CLAUDE.md in
165
+ // these is generated/vendored content, not a user file. Generic names
166
+ // like `dist`/`build` stay scannable — they can hold real docs.
167
+ "Pods",
168
+ "Carthage",
169
+ "vendor",
170
+ "deps",
171
+ "target",
172
+ ".venv",
173
+ "venv",
174
+ "site-packages",
175
+ "bower_components",
176
+ "jspm_packages",
177
+ ".terraform",
178
+ "DerivedData",
179
+ "__pycache__",
180
+ ".tox",
181
+ ".dart_tool",
182
+ ".pub-cache",
183
+ "elm-stuff",
184
+ ".stack-work",
185
+ ".pnpm-store",
186
+ ".next",
187
+ ".nuxt",
188
+ ];
189
+
190
+ /// Path prefixes never scanned, regardless of flags.
191
+ fn always_excluded(home: &Path, p: &Path) -> bool {
192
+ p.starts_with(home.join(".claude"))
193
+ }
194
+
195
+ /// Prune on a path *suffix* (component-wise), for vendored/derived trees whose
196
+ /// dirname alone is too generic to prune by name.
197
+ const PRUNE_SUFFIXES: &[&[&str]] = &[&["go", "pkg", "mod"], &["Library", "Caches"]];
198
+
199
+ fn prune_by_suffix(p: &Path) -> bool {
200
+ let depth = p.components().count();
201
+ PRUNE_SUFFIXES.iter().any(|suffix| {
202
+ suffix.len() <= depth
203
+ && p.components()
204
+ .rev()
205
+ .take(suffix.len())
206
+ .zip(suffix.iter().rev())
207
+ .all(|(c, s)| c.as_os_str() == OsStr::new(s))
208
+ })
209
+ }
210
+
211
+ /// True for directories that hold *non-migratable* rule formats — structured
212
+ /// rules with frontmatter/globs that AGENTS.md cannot express. Report-only.
213
+ fn is_rule_dir(path: &Path) -> bool {
214
+ let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
215
+ return false;
216
+ };
217
+ let parent = || {
218
+ path.parent()
219
+ .and_then(|p| p.file_name())
220
+ .and_then(|n| n.to_str())
221
+ .unwrap_or("")
222
+ };
223
+ match name {
224
+ "rules" => matches!(
225
+ parent(),
226
+ ".cursor" | ".windsurf" | ".roo" | ".continue" | ".claude" | ".kilocode" | ".trae"
227
+ ),
228
+ "steering" => parent() == ".kiro",
229
+ "microagents" => parent() == ".openhands",
230
+ "instructions" | "agents" => parent() == ".github",
231
+ ".clinerules" | ".roo" | ".junie" | ".amazonq" | ".openhands" => true,
232
+ _ => false,
233
+ }
234
+ }
235
+
236
+ /// .gitignore matcher for a repo — root .gitignore + .git/info/exclude +
237
+ /// the conventional global ignore (~/.config/git/ignore). Approximation:
238
+ /// nested .gitignore files and a custom core.excludesFile aren't consulted.
239
+ fn repo_gitignore(root: &Path) -> Gitignore {
240
+ let mut b = GitignoreBuilder::new(root);
241
+ for f in [
242
+ root.join(".gitignore"),
243
+ root.join(".git/info/exclude"),
244
+ dirs_home().join(".config/git/ignore"),
245
+ ] {
246
+ if f.is_file() {
247
+ b.add(f);
248
+ }
249
+ }
250
+ b.build().unwrap_or_else(|_| Gitignore::empty())
251
+ }
252
+
253
+ pub struct ScanResult {
254
+ pub found: Vec<FoundFile>,
255
+ /// Files whose name is a case/spelling variant of CLAUDE.md or AGENTS.md —
256
+ /// dead weight (no agent reads them), report-only.
257
+ pub variants: Vec<PathBuf>,
258
+ /// Global-ish CLAUDE.md files we refuse to touch (e.g. `~/CLAUDE.md`),
259
+ /// listed so the user knows they exist.
260
+ pub protected: Vec<PathBuf>,
261
+ /// Non-migratable rule directories inside repos (.cursor/rules,
262
+ /// .github/instructions, .junie, …) — reported so nothing is missed.
263
+ pub rule_dirs: Vec<PathBuf>,
264
+ /// Repo-root instruction files matched by .gitignore — personal files
265
+ /// the user deliberately keeps out of git; migrating them to AGENTS.md
266
+ /// would un-ignore (and potentially commit) them. Report-only.
267
+ pub gitignored: Vec<PathBuf>,
268
+ /// Repo roots whose AGENTS.md path is gitignored — a merge/rename there
269
+ /// produces a file `--pr` can't commit. Surfaced as a plan warning.
270
+ pub ignored_agents: BTreeSet<PathBuf>,
271
+ /// Walker errors (permission denied etc.), capped.
272
+ pub errors: Vec<String>,
273
+ }
274
+
275
+ /// Walk `root` in parallel, yielding every known instruction file.
276
+ pub fn scan(root: &Path, excludes: &[PathBuf], follow_links: bool) -> Result<ScanResult> {
277
+ let home = dirs_home();
278
+ let excludes: Vec<PathBuf> = excludes
279
+ .iter()
280
+ .map(|e| e.canonicalize().unwrap_or_else(|_| e.clone()))
281
+ .collect();
282
+
283
+ let found = Mutex::new(Vec::new());
284
+ let variants = Mutex::new(Vec::new());
285
+ let protected = Mutex::new(Vec::new());
286
+ let rule_dirs = Mutex::new(Vec::new());
287
+ let errors = Mutex::new(Vec::new());
288
+ let repo_cache = Mutex::new(HashMap::new());
289
+ let home_claude = home.join("CLAUDE.md");
290
+
291
+ let walker = WalkBuilder::new(root)
292
+ .follow_links(follow_links)
293
+ .hidden(false)
294
+ .git_ignore(false)
295
+ .git_global(false)
296
+ .git_exclude(false)
297
+ .require_git(false)
298
+ .threads(0)
299
+ .build_parallel();
300
+
301
+ walker.run(|| {
302
+ let found = &found;
303
+ let variants = &variants;
304
+ let protected = &protected;
305
+ let rule_dirs = &rule_dirs;
306
+ let errors = &errors;
307
+ let excludes = &excludes;
308
+ let home = &home;
309
+ let home_claude = &home_claude;
310
+ let repo_cache = &repo_cache;
311
+ Box::new(move |entry| {
312
+ let entry = match entry {
313
+ Ok(e) => e,
314
+ Err(err) => {
315
+ let mut errs = errors.lock().unwrap();
316
+ if errs.len() < 50 {
317
+ errs.push(err.to_string());
318
+ }
319
+ return WalkState::Continue;
320
+ }
321
+ };
322
+
323
+ let path = entry.path();
324
+
325
+ if always_excluded(home, path) || excludes.iter().any(|e| path.starts_with(e)) {
326
+ return WalkState::Skip;
327
+ }
328
+
329
+ let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
330
+ if is_dir {
331
+ if path != root
332
+ && (prune_by_suffix(path)
333
+ || path
334
+ .file_name()
335
+ .and_then(|n| n.to_str())
336
+ .is_some_and(|name| PRUNE_NAMES.contains(&name)))
337
+ {
338
+ return WalkState::Skip;
339
+ }
340
+ if is_rule_dir(path) && find_repo_root(path, repo_cache).is_some() {
341
+ rule_dirs.lock().unwrap().push(path.to_path_buf());
342
+ return WalkState::Skip;
343
+ }
344
+ return WalkState::Continue;
345
+ }
346
+
347
+ // ~/CLAUDE.md is a global instruction file — report, never touch.
348
+ if path == home_claude.as_path() {
349
+ protected.lock().unwrap().push(path.to_path_buf());
350
+ return WalkState::Continue;
351
+ }
352
+
353
+ let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
354
+ return WalkState::Continue;
355
+ };
356
+ if let Some(format) = format_for(path) {
357
+ let repo_root = find_repo_root(path.parent().unwrap_or(Path::new("/")), repo_cache);
358
+ // Non-Claude formats only matter inside repos — outside one
359
+ // they are dead files nobody loads, not worth reporting.
360
+ if format != Format::Claude && repo_root.is_none() {
361
+ return WalkState::Continue;
362
+ }
363
+ found.lock().unwrap().push(FoundFile {
364
+ path: path.to_path_buf(),
365
+ repo_root,
366
+ format,
367
+ });
368
+ } else if name != "AGENTS.md"
369
+ && (name.eq_ignore_ascii_case("claude.md")
370
+ || name.eq_ignore_ascii_case("agents.md")
371
+ || name.eq_ignore_ascii_case("agent.md"))
372
+ {
373
+ // Non-exact spellings — dead weight on case-sensitive systems.
374
+ variants.lock().unwrap().push(path.to_path_buf());
375
+ }
376
+ WalkState::Continue
377
+ })
378
+ });
379
+
380
+ let mut found = found.into_inner().unwrap();
381
+ found.sort_by(|a, b| a.path.cmp(&b.path));
382
+ let mut variants = variants.into_inner().unwrap();
383
+ variants.sort();
384
+ let mut rule_dirs = rule_dirs.into_inner().unwrap();
385
+ rule_dirs.sort();
386
+ rule_dirs.dedup();
387
+
388
+ // .gitignore evaluation — a gitignored root instruction file is a
389
+ // personal file; renaming it would un-ignore (and possibly commit) it.
390
+ // Also flag repos where the AGENTS.md *target* is ignored.
391
+ let mut matchers: HashMap<PathBuf, Gitignore> = HashMap::new();
392
+ let mut ignored_agents = BTreeSet::new();
393
+ let mut gitignored = Vec::new();
394
+ for root in found
395
+ .iter()
396
+ .filter_map(|f| f.repo_root.clone())
397
+ .collect::<BTreeSet<_>>()
398
+ {
399
+ let m = matchers
400
+ .entry(root.clone())
401
+ .or_insert_with(|| repo_gitignore(&root));
402
+ if m.matched(root.join(crate::plan::AGENTS), false).is_ignore() {
403
+ ignored_agents.insert(root);
404
+ }
405
+ }
406
+ let found: Vec<FoundFile> = found
407
+ .into_iter()
408
+ .filter(|f| {
409
+ if f.is_repo_root_file()
410
+ && f.repo_root
411
+ .as_ref()
412
+ .and_then(|r| matchers.get(r))
413
+ .is_some_and(|m| m.matched(&f.path, false).is_ignore())
414
+ {
415
+ gitignored.push(f.path.clone());
416
+ return false;
417
+ }
418
+ true
419
+ })
420
+ .collect();
421
+
422
+ Ok(ScanResult {
423
+ found,
424
+ variants,
425
+ protected: protected.into_inner().unwrap(),
426
+ rule_dirs,
427
+ gitignored,
428
+ ignored_agents,
429
+ errors: errors.into_inner().unwrap(),
430
+ })
431
+ }
432
+
433
+ /// Nearest ancestor (starting at `dir`) containing a `.git` entry
434
+ /// or looking like a bare repo (HEAD + objects, no worktree).
435
+ /// `cache` memoizes per-directory answers across the parallel walk.
436
+ fn find_repo_root(dir: &Path, cache: &Mutex<HashMap<PathBuf, Option<PathBuf>>>) -> Option<PathBuf> {
437
+ let mut trail = Vec::new();
438
+ let mut cur = Some(dir);
439
+ let hit = loop {
440
+ let Some(d) = cur else { break None };
441
+ if let Some(cached) = cache.lock().unwrap().get(d) {
442
+ break cached.clone();
443
+ }
444
+ if d.join(".git").symlink_metadata().is_ok() || is_bare_repo(d) {
445
+ break Some(d.to_path_buf());
446
+ }
447
+ trail.push(d.to_path_buf());
448
+ cur = d.parent();
449
+ };
450
+ // Every directory on the trail resolves to the same nearest repo.
451
+ let mut c = cache.lock().unwrap();
452
+ for d in trail {
453
+ c.insert(d, hit.clone());
454
+ }
455
+ hit
456
+ }
457
+
458
+ /// Bare repo heuristic: has HEAD + objects + refs but no `.git`.
459
+ pub fn is_bare_repo(dir: &Path) -> bool {
460
+ dir.join("HEAD").is_file()
461
+ && dir.join("objects").is_dir()
462
+ && dir.join("refs").is_dir()
463
+ && !dir.join(".git").exists()
464
+ }
465
+
466
+ fn dirs_home() -> PathBuf {
467
+ dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
468
+ }
469
+
470
+ #[cfg(test)]
471
+ mod tests {
472
+ use super::*;
473
+
474
+ /// Every format's canonical file must map back to that format — keeps
475
+ /// `label()` and `format_for()` from drifting apart.
476
+ #[test]
477
+ fn format_label_roundtrip() {
478
+ let root = Path::new("/repo");
479
+ for f in std::iter::once(Format::Claude).chain(Format::OPT_IN.iter().copied()) {
480
+ assert_eq!(
481
+ format_for(&root.join(f.label())),
482
+ Some(f),
483
+ "{:?} label {:?} not recognized",
484
+ f,
485
+ f.label()
486
+ );
487
+ }
488
+ }
489
+
490
+ #[test]
491
+ fn prune_rules() {
492
+ assert!(prune_by_suffix(Path::new("/a/go/pkg/mod")));
493
+ assert!(prune_by_suffix(Path::new("/a/Library/Caches")));
494
+ assert!(!prune_by_suffix(Path::new("/a/mod")));
495
+ assert!(!prune_by_suffix(Path::new("/a/Library")));
496
+ assert!(is_rule_dir(Path::new("/r/.cursor/rules")));
497
+ assert!(is_rule_dir(Path::new("/r/.kiro/steering")));
498
+ assert!(!is_rule_dir(Path::new("/r/rules")));
499
+ assert!(!is_rule_dir(Path::new("/r/src/rules")));
500
+ }
501
+ }