1use super::{calculate_dir_size, get_mtime, CleanableItem, SafetyLevel};
12use crate::error::Result;
13use std::collections::{HashMap, HashSet};
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum BinarySource {
20 System,
22 Homebrew,
24 HomebrewCask,
26 Cargo,
28 Pip,
30 Pipx,
32 Uv,
34 Npm,
36 Pyenv,
38 Rbenv,
40 Rvm,
42 Nvm,
44 Fnm,
46 Volta,
48 Rustup,
50 Sdkman,
52 Gvm,
54 Mise,
56 Asdf,
58 Manual,
60 Unknown,
62}
63
64impl BinarySource {
65 pub fn name(&self) -> &'static str {
67 match self {
68 Self::System => "System",
69 Self::Homebrew => "Homebrew",
70 Self::HomebrewCask => "Homebrew Cask",
71 Self::Cargo => "Cargo",
72 Self::Pip => "pip",
73 Self::Pipx => "pipx",
74 Self::Uv => "uv",
75 Self::Npm => "npm",
76 Self::Pyenv => "pyenv",
77 Self::Rbenv => "rbenv",
78 Self::Rvm => "rvm",
79 Self::Nvm => "nvm",
80 Self::Fnm => "fnm",
81 Self::Volta => "Volta",
82 Self::Rustup => "rustup",
83 Self::Sdkman => "SDKMAN",
84 Self::Gvm => "gvm",
85 Self::Mise => "mise",
86 Self::Asdf => "asdf",
87 Self::Manual => "Manual",
88 Self::Unknown => "Unknown",
89 }
90 }
91
92 pub fn is_version_manager(&self) -> bool {
94 matches!(
95 self,
96 Self::Pyenv
97 | Self::Rbenv
98 | Self::Rvm
99 | Self::Nvm
100 | Self::Fnm
101 | Self::Volta
102 | Self::Rustup
103 | Self::Sdkman
104 | Self::Gvm
105 | Self::Mise
106 | Self::Asdf
107 )
108 }
109}
110
111#[derive(Debug, Clone)]
113pub enum BinaryType {
114 Binary,
116 Symlink { target: PathBuf },
118 Wrapper { target: PathBuf },
120 HardLink,
122}
123
124#[derive(Debug, Clone)]
126pub struct BinaryInstance {
127 pub command: String,
129 pub path: PathBuf,
131 pub resolved_path: PathBuf,
133 pub source: BinarySource,
135 pub version: Option<String>,
137 pub binary_size: u64,
139 pub binary_type: BinaryType,
141 pub in_path: bool,
143 pub is_active: bool,
145}
146
147#[derive(Debug, Clone)]
149pub enum DuplicateRecommendation {
150 RemoveOldVersions { versions: Vec<String> },
152 RemoveDuplicateSource { source: BinarySource },
154 ConflictingManagers { managers: Vec<BinarySource> },
156 UnusedVersionManager { name: String, size: u64 },
158 StaleConfig { file: PathBuf, manager: String },
160 KeepAll { reason: String },
162}
163
164#[derive(Debug, Clone)]
166pub struct DuplicateGroup {
167 pub command: String,
169 pub instances: Vec<BinaryInstance>,
171 pub total_size: u64,
173 pub recommendation: DuplicateRecommendation,
175 pub safety: SafetyLevel,
177}
178
179#[derive(Debug, Default)]
181pub struct BinaryAnalysisResult {
182 pub binaries: Vec<BinaryInstance>,
184 pub duplicates: Vec<DuplicateGroup>,
186 pub unused_managers: Vec<CleanableItem>,
188 pub stale_configs: Vec<CleanableItem>,
190 pub potential_savings: u64,
192}
193
194pub struct BinaryAnalyzer {
196 home: PathBuf,
197 current_path: Vec<PathBuf>,
198}
199
200impl BinaryAnalyzer {
201 pub fn new() -> Option<Self> {
203 let home = dirs::home_dir()?;
204 let path_str = std::env::var("PATH").unwrap_or_default();
205 let current_path: Vec<PathBuf> = std::env::split_paths(&path_str).collect();
206
207 Some(Self { home, current_path })
208 }
209
210 pub fn analyze(&self) -> Result<BinaryAnalysisResult> {
212 let mut result = BinaryAnalysisResult::default();
213
214 let mut commands: Vec<&str> = vec![
217 "python",
219 "python3",
220 "python2",
221 "pip",
222 "pip3",
223 "pipx",
224 "uv",
225 "ruff",
226 "mypy",
227 "black",
228 "poetry",
229 "pdm",
230 "node",
232 "npm",
233 "npx",
234 "corepack",
235 "yarn",
236 "pnpm",
237 "bun",
238 "bunx",
239 "deno",
240 "tsx",
241 "ts-node",
242 "ruby",
244 "gem",
245 "bundle",
246 "bundler",
247 "rake",
248 "rails",
249 "go",
251 "gofmt",
252 "gopls",
253 "rustc",
255 "cargo",
256 "rustup",
257 "rustfmt",
258 "clippy-driver",
259 "java",
261 "javac",
262 "kotlin",
263 "kotlinc",
264 "scala",
265 "scalac",
266 "sbt",
267 "gradle",
268 "mvn",
269 "groovy",
270 "clojure",
271 "clj",
272 "dotnet",
274 "csc",
275 "fsc",
276 "nuget",
277 "php",
279 "composer",
280 "pecl",
281 "phpunit",
282 "laravel",
283 "perl",
285 "cpan",
286 "cpanm",
287 "elixir",
289 "erl",
290 "erlc",
291 "mix",
292 "iex",
293 "rebar3",
294 "swift",
296 "swiftc",
297 "ghc",
299 "ghci",
300 "cabal",
301 "stack",
302 "lua",
304 "luajit",
305 "luarocks",
306 "R",
308 "Rscript",
309 "julia",
311 "zig",
313 "nim",
315 "nimble",
316 "crystal",
318 "shards",
319 "ocaml",
321 "opam",
322 "dune",
323 "git",
325 "vim",
326 "nvim",
327 "emacs",
328 "code",
329 "cursor",
330 "make",
332 "cmake",
333 "ninja",
334 "meson",
335 "docker",
337 "podman",
338 "kubectl",
339 "helm",
340 ];
341
342 let python_versions: Vec<String> = (7..=14)
344 .map(|v| format!("python3.{}", v))
345 .collect();
346 for v in &python_versions {
347 commands.push(v.as_str());
348 }
349
350 let commands: Vec<&str> = commands;
352
353 result.binaries = self.discover_binaries(&commands);
354
355 result.duplicates = self.find_duplicates(&result.binaries);
357
358 result.unused_managers = self.detect_unused_version_managers()?;
360
361 result.stale_configs = self.detect_stale_configs()?;
363
364 result.potential_savings = result
366 .duplicates
367 .iter()
368 .filter(|d| d.safety != SafetyLevel::Dangerous)
369 .map(|d| {
370 d.instances
372 .iter()
373 .filter(|i| !i.is_active && i.source != BinarySource::System)
374 .map(|i| i.binary_size)
375 .sum::<u64>()
376 })
377 .sum::<u64>()
378 + result.unused_managers.iter().map(|m| m.size).sum::<u64>();
379
380 Ok(result)
381 }
382
383 fn discover_binaries(&self, commands: &[&str]) -> Vec<BinaryInstance> {
385 let mut binaries = Vec::new();
386
387 for command in commands {
388 if let Ok(output) = Command::new("which").arg("-a").arg(command).output() {
389 if output.status.success() {
390 let stdout = String::from_utf8_lossy(&output.stdout);
391 for line in stdout.lines() {
392 let path = PathBuf::from(line.trim());
393 if path.exists() {
394 if let Some(instance) = self.analyze_binary(command, &path) {
395 binaries.push(instance);
396 }
397 }
398 }
399 }
400 }
401 }
402
403 binaries
404 }
405
406 fn analyze_binary(&self, command: &str, path: &Path) -> Option<BinaryInstance> {
408 let resolved_path = self.resolve_symlink_chain(path);
409 let source = self.determine_source(&resolved_path);
410 let version = self.get_version(command, path);
411 let binary_size = std::fs::metadata(&resolved_path).ok()?.len();
412 let binary_type = self.determine_binary_type(path);
413 let in_path = self.is_in_current_path(path);
414 let is_active = self.is_active_version(command, path);
415
416 Some(BinaryInstance {
417 command: command.to_string(),
418 path: path.to_path_buf(),
419 resolved_path,
420 source,
421 version,
422 binary_size,
423 binary_type,
424 in_path,
425 is_active,
426 })
427 }
428
429 fn resolve_symlink_chain(&self, path: &Path) -> PathBuf {
431 let mut current = path.to_path_buf();
432 let mut visited = HashSet::new();
433
434 while let Ok(target) = std::fs::read_link(¤t) {
435 if visited.contains(¤t) {
436 break;
438 }
439 visited.insert(current.clone());
440
441 if target.is_relative() {
443 if let Some(parent) = current.parent() {
444 current = parent.join(&target);
445 } else {
446 current = target;
447 }
448 } else {
449 current = target;
450 }
451
452 if let Ok(canonical) = current.canonicalize() {
454 current = canonical;
455 }
456 }
457
458 current
459 }
460
461 fn determine_source(&self, path: &Path) -> BinarySource {
463 let path_str = path.to_string_lossy();
464
465 if path_str.starts_with("/opt/homebrew/") {
467 return if path_str.contains("/Caskroom/") {
468 BinarySource::HomebrewCask
469 } else {
470 BinarySource::Homebrew
471 };
472 }
473
474 if path_str.starts_with("/usr/local/Cellar/")
476 || path_str.starts_with("/usr/local/opt/")
477 || (path_str.starts_with("/usr/local/bin/")
478 && self.is_homebrew_managed(&PathBuf::from(path_str.to_string())))
479 {
480 return BinarySource::Homebrew;
481 }
482
483 if path_str.starts_with("/usr/bin/")
485 || path_str.starts_with("/bin/")
486 || path_str.starts_with("/usr/sbin/")
487 || path_str.starts_with("/sbin/")
488 {
489 return BinarySource::System;
490 }
491
492 if path_str.contains(".cargo/bin") {
494 return BinarySource::Cargo;
495 }
496
497 if path_str.contains(".rustup/") {
499 return BinarySource::Rustup;
500 }
501
502 if path_str.contains(".pyenv/") {
504 return BinarySource::Pyenv;
505 }
506
507 if path_str.contains(".rbenv/") {
509 return BinarySource::Rbenv;
510 }
511 if path_str.contains(".rvm/") {
512 return BinarySource::Rvm;
513 }
514
515 if path_str.contains(".nvm/") {
517 return BinarySource::Nvm;
518 }
519 if path_str.contains(".fnm/") || path_str.contains("fnm/node-versions") {
520 return BinarySource::Fnm;
521 }
522 if path_str.contains(".volta/") {
523 return BinarySource::Volta;
524 }
525
526 if path_str.contains(".local/share/mise/") || path_str.contains("mise/installs") {
528 return BinarySource::Mise;
529 }
530 if path_str.contains(".asdf/") {
531 return BinarySource::Asdf;
532 }
533
534 if path_str.contains(".sdkman/") {
536 return BinarySource::Sdkman;
537 }
538
539 if path_str.contains(".gvm/") {
541 return BinarySource::Gvm;
542 }
543
544 if path_str.contains(".local/share/uv/") {
546 return BinarySource::Uv;
547 }
548
549 if path_str.contains(".bun/") {
551 return BinarySource::Manual; }
553
554 if path_str.contains(".deno/") {
556 return BinarySource::Manual; }
558
559 if path_str.contains(".dotnet/") {
561 return BinarySource::Manual; }
563
564 if path_str.contains(".ghcup/") || path_str.contains(".cabal/") || path_str.contains(".stack/") {
566 return BinarySource::Manual; }
568
569 if path_str.contains(".local/bin") {
571 let pipx_venvs = self.home.join(".local/pipx/venvs");
573 if pipx_venvs.exists() {
574 if let Some(name) = path.file_name() {
576 let name_str = name.to_string_lossy();
577 if pipx_venvs.join(&*name_str).exists() {
578 return BinarySource::Pipx;
579 }
580 }
581 }
582
583 let uv_tools = self.home.join(".local/share/uv/tools");
585 if uv_tools.exists() {
586 if let Some(name) = path.file_name() {
587 let name_str = name.to_string_lossy();
588 if std::fs::read_dir(&uv_tools)
590 .map(|entries| entries.filter_map(|e| e.ok()).any(|e| {
591 e.file_name().to_string_lossy() == *name_str
592 }))
593 .unwrap_or(false)
594 {
595 return BinarySource::Uv;
596 }
597 }
598 }
599
600 return BinarySource::Pip;
602 }
603
604 if path_str.contains("node_modules") || path_str.contains(".npm") {
606 return BinarySource::Npm;
607 }
608
609 if path_str.contains("/go/bin/") {
611 return BinarySource::Manual; }
613
614 BinarySource::Unknown
615 }
616
617 fn is_homebrew_managed(&self, path: &Path) -> bool {
619 if let Ok(target) = std::fs::read_link(path) {
620 let target_str = target.to_string_lossy();
621 target_str.contains("Cellar") || target_str.contains("/opt/homebrew/")
622 } else {
623 false
624 }
625 }
626
627 fn determine_binary_type(&self, path: &Path) -> BinaryType {
629 if let Ok(target) = std::fs::read_link(path) {
631 return BinaryType::Symlink { target };
632 }
633
634 if self.is_wrapper_script(path) {
636 let resolved = self.resolve_symlink_chain(path);
637 return BinaryType::Wrapper { target: resolved };
638 }
639
640 BinaryType::Binary
641 }
642
643 fn is_wrapper_script(&self, path: &Path) -> bool {
645 if let Ok(content) = std::fs::read(path) {
647 if content.len() >= 2 && &content[0..2] == b"#!" {
648 let text = String::from_utf8_lossy(&content);
650 return text.contains("shim")
651 || text.contains("exec")
652 || text.contains("pyenv")
653 || text.contains("rbenv")
654 || text.contains("asdf");
655 }
656 }
657 false
658 }
659
660 fn get_version(&self, command: &str, path: &Path) -> Option<String> {
662 let version_flags = ["--version", "-version", "-v", "version"];
664
665 for flag in version_flags {
666 if let Ok(output) = Command::new(path).arg(flag).output() {
667 if output.status.success() || !output.stdout.is_empty() {
668 let stdout = String::from_utf8_lossy(&output.stdout);
669 if let Some(version) = self.extract_version(&stdout, command) {
670 return Some(version);
671 }
672 }
673 let stderr = String::from_utf8_lossy(&output.stderr);
675 if let Some(version) = self.extract_version(&stderr, command) {
676 return Some(version);
677 }
678 }
679 }
680
681 None
682 }
683
684 fn extract_version(&self, text: &str, _command: &str) -> Option<String> {
686 let text = text.trim();
693 let first_line = text.lines().next()?;
694
695 let version_re =
697 regex::Regex::new(r"[vV]?(\d+\.\d+(?:\.\d+)?(?:-[a-zA-Z0-9.]+)?)").ok()?;
698
699 if let Some(caps) = version_re.captures(first_line) {
700 return Some(caps.get(1)?.as_str().to_string());
701 }
702
703 None
704 }
705
706 fn is_in_current_path(&self, path: &Path) -> bool {
708 if let Some(parent) = path.parent() {
709 self.current_path.iter().any(|p| p == parent)
710 } else {
711 false
712 }
713 }
714
715 fn is_active_version(&self, command: &str, path: &Path) -> bool {
717 if let Ok(output) = Command::new("which").arg(command).output() {
719 if output.status.success() {
720 let active_path = String::from_utf8_lossy(&output.stdout);
721 let active_path = PathBuf::from(active_path.trim());
722 return active_path == path;
723 }
724 }
725 false
726 }
727
728 fn find_duplicates(&self, binaries: &[BinaryInstance]) -> Vec<DuplicateGroup> {
730 let mut groups: HashMap<String, Vec<&BinaryInstance>> = HashMap::new();
732
733 for binary in binaries {
734 groups
735 .entry(binary.command.clone())
736 .or_default()
737 .push(binary);
738 }
739
740 let mut duplicates = Vec::new();
741
742 for (command, instances) in groups {
743 if instances.len() <= 1 {
744 continue; }
746
747 let sources: HashSet<BinarySource> = instances.iter().map(|i| i.source).collect();
749
750 let versions: HashSet<&str> = instances
751 .iter()
752 .filter_map(|i| i.version.as_deref())
753 .collect();
754
755 let has_system = instances
756 .iter()
757 .any(|i| i.source == BinarySource::System);
758 let has_active = instances.iter().any(|i| i.is_active);
759
760 let recommendation = if sources.len() > 1 {
762 let managers: Vec<BinarySource> = sources
764 .iter()
765 .filter(|s| s.is_version_manager() || **s == BinarySource::Homebrew)
766 .copied()
767 .collect();
768
769 if managers.len() > 1 {
770 DuplicateRecommendation::ConflictingManagers { managers }
771 } else if let Some(non_active_source) = sources
772 .iter()
773 .find(|s| **s != BinarySource::System && !instances.iter().any(|i| i.is_active && i.source == **s))
774 {
775 DuplicateRecommendation::RemoveDuplicateSource {
776 source: *non_active_source,
777 }
778 } else {
779 DuplicateRecommendation::KeepAll {
780 reason: "Multiple sources with different purposes".to_string(),
781 }
782 }
783 } else if versions.len() > 1 {
784 let old_versions: Vec<String> = instances
786 .iter()
787 .filter(|i| !i.is_active)
788 .filter_map(|i| i.version.clone())
789 .collect();
790
791 DuplicateRecommendation::RemoveOldVersions {
792 versions: old_versions,
793 }
794 } else {
795 DuplicateRecommendation::KeepAll {
796 reason: "Same version, same source".to_string(),
797 }
798 };
799
800 let safety = if has_system && instances.len() == 1 {
802 SafetyLevel::Dangerous
803 } else if has_active {
804 let non_active_count = instances.iter().filter(|i| !i.is_active).count();
806 if non_active_count > 0 {
807 SafetyLevel::SafeWithCost
808 } else {
809 SafetyLevel::Dangerous
810 }
811 } else if sources.iter().any(|s| s.is_version_manager()) {
812 SafetyLevel::Caution
813 } else {
814 SafetyLevel::Safe
815 };
816
817 let total_size = instances.iter().map(|i| i.binary_size).sum();
818
819 duplicates.push(DuplicateGroup {
820 command,
821 instances: instances.into_iter().cloned().collect(),
822 total_size,
823 recommendation,
824 safety,
825 });
826 }
827
828 duplicates.sort_by(|a, b| b.total_size.cmp(&a.total_size));
830
831 duplicates
832 }
833
834 fn has_tool_versions_in_projects(&self) -> bool {
837 let project_dirs = [
839 "coding", "projects", "dev", "work", "code", "src",
840 "Documents", "Developer", "workspace", "repos", "git",
841 ];
842
843 for dir_name in project_dirs {
844 let dir = self.home.join(dir_name);
845 if dir.exists() && dir.is_dir() {
846 if self.find_tool_versions_shallow(&dir, 2) {
848 return true;
849 }
850 }
851 }
852
853 false
854 }
855
856 fn find_tool_versions_shallow(&self, dir: &Path, max_depth: usize) -> bool {
858 if max_depth == 0 {
859 return false;
860 }
861
862 if dir.join(".tool-versions").exists() || dir.join(".mise.toml").exists() {
864 return true;
865 }
866
867 if let Ok(entries) = std::fs::read_dir(dir) {
869 for entry in entries.filter_map(|e| e.ok()) {
870 let path = entry.path();
871 if path.is_dir() {
872 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
874 if name.starts_with('.') || name == "node_modules" || name == "target" || name == "venv" {
875 continue;
876 }
877 }
878 if self.find_tool_versions_shallow(&path, max_depth - 1) {
879 return true;
880 }
881 }
882 }
883 }
884
885 false
886 }
887
888 fn detect_unused_version_managers(&self) -> Result<Vec<CleanableItem>> {
890 let mut items = Vec::new();
891
892 let mise_dir = self.home.join(".local/share/mise");
894 if mise_dir.exists() {
895 let tool_versions = self.home.join(".tool-versions");
896 let mise_toml = self.home.join(".mise.toml");
897 let mise_config = self.home.join(".config/mise/config.toml");
898
899 let has_project_config = self.has_tool_versions_in_projects();
901
902 if !tool_versions.exists() && !mise_toml.exists() && !mise_config.exists() && !has_project_config {
903 let (size, file_count) = calculate_dir_size(&mise_dir)?;
905 if size > 10_000_000 {
906 items.push(CleanableItem {
908 name: "mise (possibly unused)".to_string(),
909 category: "Binary Analysis".to_string(),
910 subcategory: "Unused Manager".to_string(),
911 icon: "🔧",
912 path: mise_dir,
913 size,
914 file_count: Some(file_count),
915 last_modified: None,
916 description: "mise installed but no config found in ~ or common project dirs",
917 safe_to_delete: SafetyLevel::Caution,
919 clean_command: Some("rm -rf ~/.local/share/mise ~/.local/bin/mise".to_string()),
920 });
921 }
922 }
923 }
924
925 let asdf_dir = self.home.join(".asdf");
927 if asdf_dir.exists() {
928 let tool_versions = self.home.join(".tool-versions");
929 let asdf_installs = asdf_dir.join("installs");
930
931 let has_installs = asdf_installs.exists()
933 && std::fs::read_dir(&asdf_installs)
934 .map(|mut d| d.next().is_some())
935 .unwrap_or(false);
936
937 let has_project_config = self.has_tool_versions_in_projects();
939
940 if !tool_versions.exists() && !has_installs && !has_project_config {
941 let (size, file_count) = calculate_dir_size(&asdf_dir)?;
942 if size > 10_000_000 {
943 items.push(CleanableItem {
944 name: "asdf (possibly unused)".to_string(),
945 category: "Binary Analysis".to_string(),
946 subcategory: "Unused Manager".to_string(),
947 icon: "🔧",
948 path: asdf_dir,
949 size,
950 file_count: Some(file_count),
951 last_modified: None,
952 description: "asdf installed but no tools or config found",
953 safe_to_delete: SafetyLevel::Caution,
955 clean_command: Some("rm -rf ~/.asdf".to_string()),
956 });
957 }
958 }
959 }
960
961 let volta_dir = self.home.join(".volta");
963 if volta_dir.exists() {
964 let tools_dir = volta_dir.join("tools/image");
965 let has_tools = tools_dir.exists()
966 && std::fs::read_dir(&tools_dir)
967 .map(|mut d| d.next().is_some())
968 .unwrap_or(false);
969
970 if !has_tools {
971 let (size, file_count) = calculate_dir_size(&volta_dir)?;
972 if size > 10_000_000 {
973 items.push(CleanableItem {
974 name: "Volta (unused)".to_string(),
975 category: "Binary Analysis".to_string(),
976 subcategory: "Unused Manager".to_string(),
977 icon: "⚡",
978 path: volta_dir,
979 size,
980 file_count: Some(file_count),
981 last_modified: None,
982 description: "Volta installed but no Node.js versions configured",
983 safe_to_delete: SafetyLevel::Safe,
984 clean_command: Some("rm -rf ~/.volta".to_string()),
985 });
986 }
987 }
988 }
989
990 self.check_homebrew_node_duplicate(&mut items)?;
992
993 self.check_homebrew_python_duplicate(&mut items)?;
995
996 self.detect_homebrew_old_versions(&mut items)?;
998
999 self.detect_uv_cache(&mut items)?;
1001
1002 Ok(items)
1003 }
1004
1005 fn check_homebrew_python_duplicate(&self, items: &mut Vec<CleanableItem>) -> Result<()> {
1007 let has_pyenv = self.home.join(".pyenv/versions").exists()
1008 && std::fs::read_dir(self.home.join(".pyenv/versions"))
1009 .map(|mut d| d.next().is_some())
1010 .unwrap_or(false);
1011
1012 if !has_pyenv {
1013 return Ok(());
1014 }
1015
1016 let cellar_base = if PathBuf::from("/opt/homebrew/Cellar").exists() {
1018 PathBuf::from("/opt/homebrew/Cellar")
1019 } else {
1020 PathBuf::from("/usr/local/Cellar")
1021 };
1022
1023 if let Ok(entries) = std::fs::read_dir(&cellar_base) {
1025 for entry in entries.filter_map(|e| e.ok()) {
1026 let name = entry.file_name().to_string_lossy().to_string();
1027 if name.starts_with("python@") || name == "python" {
1028 let path = entry.path();
1029 let (size, file_count) = calculate_dir_size(&path)?;
1030
1031 if size > 10_000_000 {
1032 items.push(CleanableItem {
1033 name: format!("Homebrew {} (duplicate with pyenv)", name),
1034 category: "Binary Analysis".to_string(),
1035 subcategory: "Duplicate Source".to_string(),
1036 icon: "🐍",
1037 path: path.clone(),
1038 size,
1039 file_count: Some(file_count),
1040 last_modified: get_mtime(&path),
1041 description: "Homebrew Python installed alongside pyenv. Consider using only one.",
1042 safe_to_delete: SafetyLevel::SafeWithCost,
1043 clean_command: Some(format!("brew uninstall {}", name)),
1044 });
1045 }
1046 }
1047 }
1048 }
1049
1050 Ok(())
1051 }
1052
1053 fn detect_homebrew_old_versions(&self, items: &mut Vec<CleanableItem>) -> Result<()> {
1055 let cellar_base = if PathBuf::from("/opt/homebrew/Cellar").exists() {
1056 PathBuf::from("/opt/homebrew/Cellar")
1057 } else if PathBuf::from("/usr/local/Cellar").exists() {
1058 PathBuf::from("/usr/local/Cellar")
1059 } else {
1060 return Ok(());
1061 };
1062
1063 let packages_to_check = [
1065 ("python@3.11", "🐍", "Python 3.11"),
1066 ("python@3.12", "🐍", "Python 3.12"),
1067 ("python@3.13", "🐍", "Python 3.13"),
1068 ("python", "🐍", "Python"),
1069 ("node", "📦", "Node.js"),
1070 ("ruby", "💎", "Ruby"),
1071 ("go", "🐹", "Go"),
1072 ("rust", "🦀", "Rust"),
1073 ("openjdk", "☕", "OpenJDK"),
1074 ("openjdk@17", "☕", "OpenJDK 17"),
1075 ("openjdk@21", "☕", "OpenJDK 21"),
1076 ("php", "🐘", "PHP"),
1077 ("php@8.2", "🐘", "PHP 8.2"),
1078 ("php@8.3", "🐘", "PHP 8.3"),
1079 ("perl", "🐪", "Perl"),
1080 ("lua", "🌙", "Lua"),
1081 ("erlang", "📡", "Erlang"),
1082 ("elixir", "💧", "Elixir"),
1083 ("dotnet", "🔷", ".NET"),
1084 ];
1085
1086 for (package, icon, display_name) in packages_to_check {
1087 let package_dir = cellar_base.join(package);
1088 if !package_dir.exists() {
1089 continue;
1090 }
1091
1092 let mut versions: Vec<_> = std::fs::read_dir(&package_dir)
1094 .ok()
1095 .map(|entries| {
1096 entries
1097 .filter_map(|e| e.ok())
1098 .filter(|e| e.path().is_dir())
1099 .collect()
1100 })
1101 .unwrap_or_default();
1102
1103 if versions.len() <= 1 {
1104 continue; }
1106
1107 versions.sort_by(|a, b| {
1109 let a_time = a.metadata().and_then(|m| m.modified()).ok();
1110 let b_time = b.metadata().and_then(|m| m.modified()).ok();
1111 a_time.cmp(&b_time)
1112 });
1113
1114 for old_version in versions.iter().take(versions.len() - 1) {
1116 let path = old_version.path();
1117 let version_name = old_version.file_name().to_string_lossy().to_string();
1118 let (size, file_count) = calculate_dir_size(&path)?;
1119
1120 if size > 5_000_000 {
1121 items.push(CleanableItem {
1123 name: format!("{} {} (old Homebrew version)", display_name, version_name),
1124 category: "Binary Analysis".to_string(),
1125 subcategory: "Old Version".to_string(),
1126 icon,
1127 path: path.clone(),
1128 size,
1129 file_count: Some(file_count),
1130 last_modified: get_mtime(&path),
1131 description: "Old Homebrew version. Run 'brew cleanup' to remove all old versions.",
1132 safe_to_delete: SafetyLevel::Safe,
1133 clean_command: Some("brew cleanup".to_string()),
1134 });
1135 }
1136 }
1137 }
1138
1139 Ok(())
1140 }
1141
1142 fn detect_uv_cache(&self, items: &mut Vec<CleanableItem>) -> Result<()> {
1144 let uv_cache = self.home.join(".cache/uv");
1146 if uv_cache.exists() {
1147 let (size, file_count) = calculate_dir_size(&uv_cache)?;
1148 if size > 100_000_000 {
1149 items.push(CleanableItem {
1151 name: "uv cache".to_string(),
1152 category: "Binary Analysis".to_string(),
1153 subcategory: "Package Cache".to_string(),
1154 icon: "🐍",
1155 path: uv_cache,
1156 size,
1157 file_count: Some(file_count),
1158 last_modified: None,
1159 description: "uv package manager cache. Safe to clean, packages will be re-downloaded.",
1160 safe_to_delete: SafetyLevel::Safe,
1161 clean_command: Some("uv cache clean".to_string()),
1162 });
1163 }
1164 }
1165
1166 let uv_python = self.home.join(".local/share/uv/python");
1168 if uv_python.exists() {
1169 if let Ok(entries) = std::fs::read_dir(&uv_python) {
1170 for entry in entries.filter_map(|e| e.ok()) {
1171 let path = entry.path();
1172 if !path.is_dir() {
1173 continue;
1174 }
1175
1176 let version = path.file_name()
1177 .map(|n| n.to_string_lossy().to_string())
1178 .unwrap_or_else(|| "Unknown".to_string());
1179
1180 let (size, file_count) = calculate_dir_size(&path)?;
1181 if size > 50_000_000 {
1182 items.push(CleanableItem {
1183 name: format!("Python {} (uv managed)", version),
1184 category: "Binary Analysis".to_string(),
1185 subcategory: "uv".to_string(),
1186 icon: "🐍",
1187 path: path.clone(),
1188 size,
1189 file_count: Some(file_count),
1190 last_modified: get_mtime(&path),
1191 description: "Python version managed by uv. Can be reinstalled with 'uv python install'.",
1192 safe_to_delete: SafetyLevel::SafeWithCost,
1193 clean_command: None,
1194 });
1195 }
1196 }
1197 }
1198 }
1199
1200 Ok(())
1201 }
1202
1203 fn check_homebrew_node_duplicate(&self, items: &mut Vec<CleanableItem>) -> Result<()> {
1205 let homebrew_node = PathBuf::from("/opt/homebrew/bin/node");
1206 let homebrew_node_intel = PathBuf::from("/usr/local/bin/node");
1207
1208 let node_path = if homebrew_node.exists() {
1209 Some(homebrew_node)
1210 } else if homebrew_node_intel.exists()
1211 && self.is_homebrew_managed(&homebrew_node_intel)
1212 {
1213 Some(homebrew_node_intel)
1214 } else {
1215 None
1216 };
1217
1218 if let Some(brew_node) = node_path {
1219 let has_fnm = self.home.join("Library/Application Support/fnm/node-versions").exists()
1221 || self.home.join(".local/share/fnm/node-versions").exists();
1222 let has_nvm = self.home.join(".nvm/versions/node").exists();
1223 let has_volta = self.home.join(".volta/tools/image/node").exists();
1224
1225 if has_fnm || has_nvm || has_volta {
1226 let manager = if has_fnm {
1228 "fnm"
1229 } else if has_nvm {
1230 "nvm"
1231 } else {
1232 "Volta"
1233 };
1234
1235 let cellar_path = if brew_node.starts_with("/opt/homebrew") {
1237 PathBuf::from("/opt/homebrew/Cellar/node")
1238 } else {
1239 PathBuf::from("/usr/local/Cellar/node")
1240 };
1241
1242 if cellar_path.exists() {
1243 let (size, file_count) = calculate_dir_size(&cellar_path)?;
1244
1245 items.push(CleanableItem {
1246 name: format!("Homebrew Node (duplicate with {})", manager),
1247 category: "Binary Analysis".to_string(),
1248 subcategory: "Duplicate Source".to_string(),
1249 icon: "📦",
1250 path: cellar_path,
1251 size,
1252 file_count: Some(file_count),
1253 last_modified: None,
1254 description: "Homebrew Node.js installed alongside a version manager. Consider removing one.",
1255 safe_to_delete: SafetyLevel::SafeWithCost,
1256 clean_command: Some("brew uninstall node".to_string()),
1257 });
1258 }
1259 }
1260 }
1261
1262 Ok(())
1263 }
1264
1265 fn detect_stale_configs(&self) -> Result<Vec<CleanableItem>> {
1270 let mut items = Vec::new();
1271
1272 let rc_files = [
1273 self.home.join(".zshrc"),
1274 self.home.join(".bashrc"),
1275 self.home.join(".bash_profile"),
1276 self.home.join(".profile"),
1277 ];
1278
1279 for rc_file in rc_files {
1280 if !rc_file.exists() {
1281 continue;
1282 }
1283
1284 if let Ok(content) = std::fs::read_to_string(&rc_file) {
1285 if (content.contains("NVM_DIR") || content.contains("nvm.sh"))
1287 && !self.home.join(".nvm").exists()
1288 {
1289 items.push(CleanableItem {
1290 name: format!(
1291 "[MANUAL] NVM stale config in {}",
1292 rc_file.file_name().unwrap_or_default().to_string_lossy()
1293 ),
1294 category: "Binary Analysis".to_string(),
1295 subcategory: "Stale Config".to_string(),
1296 icon: "⚠️",
1297 path: self.home.join(".devsweep-manual-edit-required"),
1299 size: 0,
1300 file_count: None,
1301 last_modified: get_mtime(&rc_file),
1302 description: "NVM in shell rc but not installed. MANUAL EDIT REQUIRED - DO NOT DELETE!",
1303 safe_to_delete: SafetyLevel::Dangerous,
1304 clean_command: Some(format!(
1305 "Manual: Edit {} and remove NVM_DIR/nvm.sh lines",
1306 rc_file.display()
1307 )),
1308 });
1309 }
1310
1311 if (content.contains("PYENV_ROOT") || content.contains("pyenv init"))
1313 && !self.home.join(".pyenv").exists()
1314 {
1315 items.push(CleanableItem {
1316 name: format!(
1317 "[MANUAL] pyenv stale config in {}",
1318 rc_file.file_name().unwrap_or_default().to_string_lossy()
1319 ),
1320 category: "Binary Analysis".to_string(),
1321 subcategory: "Stale Config".to_string(),
1322 icon: "⚠️",
1323 path: self.home.join(".devsweep-manual-edit-required"),
1325 size: 0,
1326 file_count: None,
1327 last_modified: get_mtime(&rc_file),
1328 description: "pyenv in shell rc but not installed. MANUAL EDIT REQUIRED - DO NOT DELETE!",
1329 safe_to_delete: SafetyLevel::Dangerous,
1330 clean_command: Some(format!(
1331 "Manual: Edit {} and remove PYENV_ROOT/pyenv init lines",
1332 rc_file.display()
1333 )),
1334 });
1335 }
1336
1337 if (content.contains("RBENV_ROOT") || content.contains("rbenv init"))
1339 && !self.home.join(".rbenv").exists()
1340 {
1341 items.push(CleanableItem {
1342 name: format!(
1343 "[MANUAL] rbenv stale config in {}",
1344 rc_file.file_name().unwrap_or_default().to_string_lossy()
1345 ),
1346 category: "Binary Analysis".to_string(),
1347 subcategory: "Stale Config".to_string(),
1348 icon: "⚠️",
1349 path: self.home.join(".devsweep-manual-edit-required"),
1351 size: 0,
1352 file_count: None,
1353 last_modified: get_mtime(&rc_file),
1354 description: "rbenv in shell rc but not installed. MANUAL EDIT REQUIRED - DO NOT DELETE!",
1355 safe_to_delete: SafetyLevel::Dangerous,
1356 clean_command: Some(format!(
1357 "Manual: Edit {} and remove RBENV_ROOT/rbenv init lines",
1358 rc_file.display()
1359 )),
1360 });
1361 }
1362 }
1363 }
1364
1365 Ok(items)
1366 }
1367
1368 pub fn to_cleanable_items(&self, result: &BinaryAnalysisResult) -> Vec<CleanableItem> {
1370 let mut items = Vec::new();
1371
1372 for group in &result.duplicates {
1374 if group.instances.iter().all(|i| i.is_active || i.source == BinarySource::System) {
1376 continue;
1377 }
1378
1379 let removable: Vec<&BinaryInstance> = group
1381 .instances
1382 .iter()
1383 .filter(|i| !i.is_active && i.source != BinarySource::System)
1384 .collect();
1385
1386 if removable.is_empty() {
1387 continue;
1388 }
1389
1390 for instance in removable {
1391 let description = match &group.recommendation {
1392 DuplicateRecommendation::ConflictingManagers { managers } => {
1393 let names: Vec<&str> = managers.iter().map(|m| m.name()).collect();
1394 format!(
1395 "Duplicate from {}. Also installed via: {}",
1396 instance.source.name(),
1397 names.join(", ")
1398 )
1399 }
1400 DuplicateRecommendation::RemoveDuplicateSource { source } => {
1401 format!("Duplicate installation from {}", source.name())
1402 }
1403 DuplicateRecommendation::RemoveOldVersions { versions } => {
1404 format!(
1405 "Old version. Newer versions available: {}",
1406 versions.join(", ")
1407 )
1408 }
1409 _ => "Duplicate binary".to_string(),
1410 };
1411
1412 let (size, path_to_clean) =
1414 if let Some(version_dir) = self.get_version_install_dir(&instance.path) {
1415 let (dir_size, _) = calculate_dir_size(&version_dir).unwrap_or((0, 0));
1416 (dir_size, version_dir)
1417 } else {
1418 (instance.binary_size, instance.resolved_path.clone())
1419 };
1420
1421 let clean_command = self.get_clean_command(instance);
1422
1423 let description_static: &'static str = Box::leak(description.into_boxed_str());
1426
1427 items.push(CleanableItem {
1428 name: format!(
1429 "{} {} ({})",
1430 group.command,
1431 instance.version.as_deref().unwrap_or(""),
1432 instance.source.name()
1433 ),
1434 category: "Binary Analysis".to_string(),
1435 subcategory: "Duplicates".to_string(),
1436 icon: self.get_icon_for_command(&group.command),
1437 path: path_to_clean,
1438 size,
1439 file_count: None,
1440 last_modified: get_mtime(&instance.path),
1441 description: description_static,
1442 safe_to_delete: group.safety,
1443 clean_command,
1444 });
1445 }
1446 }
1447
1448 items.extend(result.unused_managers.clone());
1450
1451 items.extend(result.stale_configs.clone());
1453
1454 items.sort_by(|a, b| b.size.cmp(&a.size));
1456
1457 items
1458 }
1459
1460 fn get_version_install_dir(&self, binary_path: &Path) -> Option<PathBuf> {
1462 let path_str = binary_path.to_string_lossy();
1463
1464 if path_str.contains("fnm/node-versions/") {
1466 let parts: Vec<&str> = path_str.split("fnm/node-versions/").collect();
1467 if parts.len() > 1 {
1468 let version = parts[1].split('/').next()?;
1469 #[cfg(target_os = "macos")]
1470 let base = self.home.join("Library/Application Support/fnm/node-versions");
1471 #[cfg(not(target_os = "macos"))]
1472 let base = self.home.join(".local/share/fnm/node-versions");
1473 return Some(base.join(version));
1474 }
1475 }
1476
1477 if path_str.contains(".nvm/versions/node/") {
1479 let parts: Vec<&str> = path_str.split(".nvm/versions/node/").collect();
1480 if parts.len() > 1 {
1481 let version = parts[1].split('/').next()?;
1482 return Some(self.home.join(".nvm/versions/node").join(version));
1483 }
1484 }
1485
1486 if path_str.contains(".pyenv/versions/") {
1488 let parts: Vec<&str> = path_str.split(".pyenv/versions/").collect();
1489 if parts.len() > 1 {
1490 let version = parts[1].split('/').next()?;
1491 return Some(self.home.join(".pyenv/versions").join(version));
1492 }
1493 }
1494
1495 if path_str.contains("/Cellar/") {
1497 let parts: Vec<&str> = path_str.split("/Cellar/").collect();
1498 if parts.len() > 1 {
1499 let rest = parts[1];
1500 let components: Vec<&str> = rest.split('/').collect();
1501 if components.len() >= 2 {
1502 let package = components[0];
1503 let version = components[1];
1504 let cellar_base = if path_str.contains("/opt/homebrew/") {
1505 PathBuf::from("/opt/homebrew/Cellar")
1506 } else {
1507 PathBuf::from("/usr/local/Cellar")
1508 };
1509 return Some(cellar_base.join(package).join(version));
1510 }
1511 }
1512 }
1513
1514 None
1515 }
1516
1517 fn get_clean_command(&self, instance: &BinaryInstance) -> Option<String> {
1519 match instance.source {
1520 BinarySource::Homebrew => Some(format!(
1521 "brew uninstall {}",
1522 instance.command
1523 )),
1524 BinarySource::Nvm => instance
1525 .version
1526 .as_ref()
1527 .map(|v| format!("nvm uninstall {}", v)),
1528 BinarySource::Fnm => instance
1529 .version
1530 .as_ref()
1531 .map(|v| format!("fnm uninstall {}", v)),
1532 BinarySource::Pyenv => instance
1533 .version
1534 .as_ref()
1535 .map(|v| format!("pyenv uninstall -f {}", v)),
1536 BinarySource::Rbenv => instance
1537 .version
1538 .as_ref()
1539 .map(|v| format!("rbenv uninstall -f {}", v)),
1540 BinarySource::Cargo => Some(format!("cargo uninstall {}", instance.command)),
1541 BinarySource::Pipx => Some(format!("pipx uninstall {}", instance.command)),
1542 BinarySource::Npm => Some(format!("npm uninstall -g {}", instance.command)),
1543 _ => None,
1544 }
1545 }
1546
1547 fn get_icon_for_command(&self, command: &str) -> &'static str {
1549 if command.starts_with("python") || command.starts_with("pip") {
1550 "🐍"
1551 } else if command.starts_with("node")
1552 || command.starts_with("npm")
1553 || command.starts_with("npx")
1554 {
1555 "📦"
1556 } else if command.starts_with("ruby") || command.starts_with("gem") {
1557 "💎"
1558 } else if command.starts_with("go") {
1559 "🐹"
1560 } else if command.starts_with("rust") || command.starts_with("cargo") {
1561 "🦀"
1562 } else if command.starts_with("java") {
1563 "☕"
1564 } else {
1565 "🔧"
1566 }
1567 }
1568}
1569
1570impl Default for BinaryAnalyzer {
1571 fn default() -> Self {
1572 Self::new().expect("BinaryAnalyzer requires home directory")
1573 }
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578 use super::*;
1579
1580 #[test]
1581 fn test_binary_analyzer_creation() {
1582 let analyzer = BinaryAnalyzer::new();
1583 assert!(analyzer.is_some());
1584 }
1585
1586 #[test]
1587 fn test_source_detection() {
1588 let analyzer = BinaryAnalyzer::new().unwrap();
1589
1590 assert_eq!(
1592 analyzer.determine_source(Path::new("/usr/bin/python3")),
1593 BinarySource::System
1594 );
1595 assert_eq!(
1596 analyzer.determine_source(Path::new("/bin/bash")),
1597 BinarySource::System
1598 );
1599
1600 assert_eq!(
1602 analyzer.determine_source(Path::new("/opt/homebrew/bin/node")),
1603 BinarySource::Homebrew
1604 );
1605 assert_eq!(
1606 analyzer.determine_source(Path::new("/opt/homebrew/Cellar/python@3.12/3.12.0/bin/python3")),
1607 BinarySource::Homebrew
1608 );
1609
1610 let home = dirs::home_dir().unwrap();
1612 let cargo_path = home.join(".cargo/bin/rg");
1613 assert_eq!(
1614 analyzer.determine_source(&cargo_path),
1615 BinarySource::Cargo
1616 );
1617 }
1618
1619 #[test]
1620 fn test_symlink_resolution() {
1621 let analyzer = BinaryAnalyzer::new().unwrap();
1622
1623 let python_path = Path::new("/usr/bin/python3");
1625 if python_path.exists() {
1626 let resolved = analyzer.resolve_symlink_chain(python_path);
1627 assert!(resolved.exists());
1628 }
1629 }
1630
1631 #[test]
1632 fn test_binary_analysis() {
1633 if let Some(analyzer) = BinaryAnalyzer::new() {
1634 let result = analyzer.analyze().unwrap();
1635
1636 println!("Found {} binaries", result.binaries.len());
1637 println!("Found {} duplicate groups", result.duplicates.len());
1638 println!("Found {} unused managers", result.unused_managers.len());
1639 println!("Found {} stale configs", result.stale_configs.len());
1640 println!(
1641 "Potential savings: {} bytes",
1642 result.potential_savings
1643 );
1644
1645 for dup in &result.duplicates {
1646 println!(
1647 " Duplicate: {} ({} instances)",
1648 dup.command,
1649 dup.instances.len()
1650 );
1651 for inst in &dup.instances {
1652 println!(
1653 " - {} ({}) {}",
1654 inst.path.display(),
1655 inst.source.name(),
1656 if inst.is_active { "[ACTIVE]" } else { "" }
1657 );
1658 }
1659 }
1660 }
1661 }
1662}