Skip to main content

null_e/cleaners/
binaries.rs

1//! System-Wide Binary & Runtime Analyzer
2//!
3//! Detects all installed binaries from various sources:
4//! - System paths (/usr/bin/, /bin/)
5//! - Homebrew (/opt/homebrew/, /usr/local/Cellar/)
6//! - Version managers (nvm, pyenv, rbenv, fnm, volta, mise, asdf)
7//! - Package managers (pip, npm, cargo, pipx, uv)
8//!
9//! Identifies duplicates, conflicting installations, and unused version managers.
10
11use 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/// Binary source - where a binary was installed from
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum BinarySource {
20    /// System binary (/usr/bin/, /bin/)
21    System,
22    /// Homebrew (Apple Silicon: /opt/homebrew/, Intel: /usr/local/Cellar/)
23    Homebrew,
24    /// Homebrew cask
25    HomebrewCask,
26    /// Cargo/Rust (~/.cargo/bin/)
27    Cargo,
28    /// pip install --user
29    Pip,
30    /// pipx (~/.local/pipx/)
31    Pipx,
32    /// uv installed tools (~/.local/bin/ from uv)
33    Uv,
34    /// npm global packages
35    Npm,
36    /// pyenv (~/.pyenv/)
37    Pyenv,
38    /// rbenv (~/.rbenv/)
39    Rbenv,
40    /// rvm (~/.rvm/)
41    Rvm,
42    /// nvm (~/.nvm/)
43    Nvm,
44    /// fnm (Fast Node Manager)
45    Fnm,
46    /// volta (~/.volta/)
47    Volta,
48    /// rustup (~/.rustup/)
49    Rustup,
50    /// sdkman (~/.sdkman/)
51    Sdkman,
52    /// gvm (~/.gvm/)
53    Gvm,
54    /// mise (~/.local/share/mise/)
55    Mise,
56    /// asdf (~/.asdf/)
57    Asdf,
58    /// Manual installation
59    Manual,
60    /// Unknown source
61    Unknown,
62}
63
64impl BinarySource {
65    /// Get display name
66    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    /// Is this a version manager?
93    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/// Binary type - what kind of binary is this?
112#[derive(Debug, Clone)]
113pub enum BinaryType {
114    /// Actual compiled binary
115    Binary,
116    /// Symlink to another file
117    Symlink { target: PathBuf },
118    /// Shell script wrapper (shim)
119    Wrapper { target: PathBuf },
120    /// Hard link
121    HardLink,
122}
123
124/// A single binary instance found on the system
125#[derive(Debug, Clone)]
126pub struct BinaryInstance {
127    /// Command name (e.g., "python3")
128    pub command: String,
129    /// Full path to binary
130    pub path: PathBuf,
131    /// Resolved path after following symlinks
132    pub resolved_path: PathBuf,
133    /// Source of this binary
134    pub source: BinarySource,
135    /// Version string (if detected)
136    pub version: Option<String>,
137    /// Binary size in bytes
138    pub binary_size: u64,
139    /// Type of binary
140    pub binary_type: BinaryType,
141    /// Is this binary in the current PATH?
142    pub in_path: bool,
143    /// Is this the active/default version?
144    pub is_active: bool,
145}
146
147/// Recommendation for handling duplicate binaries
148#[derive(Debug, Clone)]
149pub enum DuplicateRecommendation {
150    /// Old versions that can be removed
151    RemoveOldVersions { versions: Vec<String> },
152    /// Duplicate from another source
153    RemoveDuplicateSource { source: BinarySource },
154    /// Conflicting version managers
155    ConflictingManagers { managers: Vec<BinarySource> },
156    /// Unused version manager
157    UnusedVersionManager { name: String, size: u64 },
158    /// Stale config in shell rc file
159    StaleConfig { file: PathBuf, manager: String },
160    /// Keep all instances
161    KeepAll { reason: String },
162}
163
164/// A group of duplicate binaries
165#[derive(Debug, Clone)]
166pub struct DuplicateGroup {
167    /// Command name
168    pub command: String,
169    /// All instances of this command
170    pub instances: Vec<BinaryInstance>,
171    /// Total size of all instances
172    pub total_size: u64,
173    /// Recommendation for handling
174    pub recommendation: DuplicateRecommendation,
175    /// Safety level for cleanup
176    pub safety: SafetyLevel,
177}
178
179/// Result of binary analysis
180#[derive(Debug, Default)]
181pub struct BinaryAnalysisResult {
182    /// All discovered binaries
183    pub binaries: Vec<BinaryInstance>,
184    /// Duplicate groups
185    pub duplicates: Vec<DuplicateGroup>,
186    /// Unused version managers
187    pub unused_managers: Vec<CleanableItem>,
188    /// Stale configurations
189    pub stale_configs: Vec<CleanableItem>,
190    /// Total potential savings
191    pub potential_savings: u64,
192}
193
194/// Binary Analyzer - discovers and analyzes system binaries
195pub struct BinaryAnalyzer {
196    home: PathBuf,
197    current_path: Vec<PathBuf>,
198}
199
200impl BinaryAnalyzer {
201    /// Create a new binary analyzer
202    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    /// Perform full binary analysis
211    pub fn analyze(&self) -> Result<BinaryAnalysisResult> {
212        let mut result = BinaryAnalysisResult::default();
213
214        // Discover binaries for key commands
215        // Build dynamic list including all Python versions
216        let mut commands: Vec<&str> = vec![
217            // Python (base commands)
218            "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.js / JavaScript
231            "node",
232            "npm",
233            "npx",
234            "corepack",
235            "yarn",
236            "pnpm",
237            "bun",
238            "bunx",
239            "deno",
240            "tsx",
241            "ts-node",
242            // Ruby
243            "ruby",
244            "gem",
245            "bundle",
246            "bundler",
247            "rake",
248            "rails",
249            // Go
250            "go",
251            "gofmt",
252            "gopls",
253            // Rust
254            "rustc",
255            "cargo",
256            "rustup",
257            "rustfmt",
258            "clippy-driver",
259            // Java / JVM
260            "java",
261            "javac",
262            "kotlin",
263            "kotlinc",
264            "scala",
265            "scalac",
266            "sbt",
267            "gradle",
268            "mvn",
269            "groovy",
270            "clojure",
271            "clj",
272            // .NET / C#
273            "dotnet",
274            "csc",
275            "fsc",
276            "nuget",
277            // PHP
278            "php",
279            "composer",
280            "pecl",
281            "phpunit",
282            "laravel",
283            // Perl
284            "perl",
285            "cpan",
286            "cpanm",
287            // Elixir / Erlang
288            "elixir",
289            "erl",
290            "erlc",
291            "mix",
292            "iex",
293            "rebar3",
294            // Swift
295            "swift",
296            "swiftc",
297            // Haskell
298            "ghc",
299            "ghci",
300            "cabal",
301            "stack",
302            // Lua
303            "lua",
304            "luajit",
305            "luarocks",
306            // R
307            "R",
308            "Rscript",
309            // Julia
310            "julia",
311            // Zig
312            "zig",
313            // Nim
314            "nim",
315            "nimble",
316            // Crystal
317            "crystal",
318            "shards",
319            // OCaml
320            "ocaml",
321            "opam",
322            "dune",
323            // Common dev tools
324            "git",
325            "vim",
326            "nvim",
327            "emacs",
328            "code",
329            "cursor",
330            // Build tools
331            "make",
332            "cmake",
333            "ninja",
334            "meson",
335            // Container tools
336            "docker",
337            "podman",
338            "kubectl",
339            "helm",
340        ];
341
342        // Add Python version-specific commands (3.7 through 3.14)
343        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        // Convert to slice for discover_binaries
351        let commands: Vec<&str> = commands;
352
353        result.binaries = self.discover_binaries(&commands);
354
355        // Find duplicates
356        result.duplicates = self.find_duplicates(&result.binaries);
357
358        // Detect unused version managers
359        result.unused_managers = self.detect_unused_version_managers()?;
360
361        // Detect stale configs
362        result.stale_configs = self.detect_stale_configs()?;
363
364        // Calculate potential savings
365        result.potential_savings = result
366            .duplicates
367            .iter()
368            .filter(|d| d.safety != SafetyLevel::Dangerous)
369            .map(|d| {
370                // Don't count active/system binaries
371                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    /// Discover binaries for given commands using `which -a`
384    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    /// Analyze a single binary
407    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    /// Resolve symlink chain to find actual binary
430    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(&current) {
435            if visited.contains(&current) {
436                // Circular symlink
437                break;
438            }
439            visited.insert(current.clone());
440
441            // Handle relative symlinks
442            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            // Canonicalize to resolve ..
453            if let Ok(canonical) = current.canonicalize() {
454                current = canonical;
455            }
456        }
457
458        current
459    }
460
461    /// Determine the source of a binary based on its path
462    fn determine_source(&self, path: &Path) -> BinarySource {
463        let path_str = path.to_string_lossy();
464
465        // Homebrew (Apple Silicon)
466        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        // Homebrew (Intel Mac)
475        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        // System paths
484        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        // Cargo/Rust
493        if path_str.contains(".cargo/bin") {
494            return BinarySource::Cargo;
495        }
496
497        // Rustup
498        if path_str.contains(".rustup/") {
499            return BinarySource::Rustup;
500        }
501
502        // Python version managers
503        if path_str.contains(".pyenv/") {
504            return BinarySource::Pyenv;
505        }
506
507        // Ruby version managers
508        if path_str.contains(".rbenv/") {
509            return BinarySource::Rbenv;
510        }
511        if path_str.contains(".rvm/") {
512            return BinarySource::Rvm;
513        }
514
515        // Node version managers
516        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        // Modern version managers
527        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        // Java version managers
535        if path_str.contains(".sdkman/") {
536            return BinarySource::Sdkman;
537        }
538
539        // Go version managers
540        if path_str.contains(".gvm/") {
541            return BinarySource::Gvm;
542        }
543
544        // uv managed Python
545        if path_str.contains(".local/share/uv/") {
546            return BinarySource::Uv;
547        }
548
549        // Bun
550        if path_str.contains(".bun/") {
551            return BinarySource::Manual; // Bun-installed binaries
552        }
553
554        // Deno
555        if path_str.contains(".deno/") {
556            return BinarySource::Manual; // Deno-installed
557        }
558
559        // .NET
560        if path_str.contains(".dotnet/") {
561            return BinarySource::Manual; // .NET SDK
562        }
563
564        // Haskell
565        if path_str.contains(".ghcup/") || path_str.contains(".cabal/") || path_str.contains(".stack/") {
566            return BinarySource::Manual; // Haskell toolchain
567        }
568
569        // pip/pipx/uv installed in ~/.local/bin
570        if path_str.contains(".local/bin") {
571            // Check if from pipx
572            let pipx_venvs = self.home.join(".local/pipx/venvs");
573            if pipx_venvs.exists() {
574                // Check if this binary is from a pipx venv
575                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            // Check if uv tool
584            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                    // Check in uv tools directory
589                    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            // Could be uv or pip
601            return BinarySource::Pip;
602        }
603
604        // npm global
605        if path_str.contains("node_modules") || path_str.contains(".npm") {
606            return BinarySource::Npm;
607        }
608
609        // Go binaries in GOPATH/bin
610        if path_str.contains("/go/bin/") {
611            return BinarySource::Manual; // go install
612        }
613
614        BinarySource::Unknown
615    }
616
617    /// Check if a /usr/local/bin path is managed by Homebrew
618    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    /// Determine the type of binary
628    fn determine_binary_type(&self, path: &Path) -> BinaryType {
629        // Check if symlink
630        if let Ok(target) = std::fs::read_link(path) {
631            return BinaryType::Symlink { target };
632        }
633
634        // Check if it's a shim/wrapper script
635        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    /// Check if a file is a wrapper/shim script
644    fn is_wrapper_script(&self, path: &Path) -> bool {
645        // Read first few bytes to check for shebang
646        if let Ok(content) = std::fs::read(path) {
647            if content.len() >= 2 && &content[0..2] == b"#!" {
648                // It's a script - check for common shim patterns
649                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    /// Get version of a binary
661    fn get_version(&self, command: &str, path: &Path) -> Option<String> {
662        // Try common version flags
663        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                // Try stderr too
674                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    /// Extract version number from version output
685    fn extract_version(&self, text: &str, _command: &str) -> Option<String> {
686        // Common patterns:
687        // "Python 3.13.5"
688        // "node v22.22.0"
689        // "ruby 3.4.7"
690        // "go version go1.21.0"
691
692        let text = text.trim();
693        let first_line = text.lines().next()?;
694
695        // Try to find version number pattern
696        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    /// Check if path is in current PATH
707    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    /// Check if this is the active version (first in PATH)
716    fn is_active_version(&self, command: &str, path: &Path) -> bool {
717        // Run `which` to get the active version
718        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    /// Find duplicate binaries
729    fn find_duplicates(&self, binaries: &[BinaryInstance]) -> Vec<DuplicateGroup> {
730        // Group by command name
731        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; // Not a duplicate
745            }
746
747            // Analyze duplicate type
748            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            // Determine recommendation
761            let recommendation = if sources.len() > 1 {
762                // Same tool from multiple sources
763                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                // Multiple versions from same source
785                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            // Determine safety level
801            let safety = if has_system && instances.len() == 1 {
802                SafetyLevel::Dangerous
803            } else if has_active {
804                // Can remove non-active duplicates
805                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        // Sort by size
829        duplicates.sort_by(|a, b| b.total_size.cmp(&a.total_size));
830
831        duplicates
832    }
833
834    /// Check if any common project directories contain .tool-versions files
835    /// This helps avoid false positives when detecting "unused" version managers
836    fn has_tool_versions_in_projects(&self) -> bool {
837        // Common project directory names
838        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                // Quick shallow check (max 2 levels deep to avoid long scans)
847                if self.find_tool_versions_shallow(&dir, 2) {
848                    return true;
849                }
850            }
851        }
852
853        false
854    }
855
856    /// Shallow search for .tool-versions (max depth levels)
857    fn find_tool_versions_shallow(&self, dir: &Path, max_depth: usize) -> bool {
858        if max_depth == 0 {
859            return false;
860        }
861
862        // Check current directory
863        if dir.join(".tool-versions").exists() || dir.join(".mise.toml").exists() {
864            return true;
865        }
866
867        // Check subdirectories
868        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                    // Skip hidden dirs and common non-project dirs
873                    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    /// Detect unused version managers
889    fn detect_unused_version_managers(&self) -> Result<Vec<CleanableItem>> {
890        let mut items = Vec::new();
891
892        // Check mise
893        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            // Also check common project directories for .tool-versions
900            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                // mise installed but not configured anywhere
904                let (size, file_count) = calculate_dir_size(&mise_dir)?;
905                if size > 10_000_000 {
906                    // 10MB minimum
907                    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                        // Changed to Caution - user should verify no projects use it
918                        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        // Check asdf
926        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            // Check if any tools are actually installed
932            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            // Also check common project directories
938            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                        // Changed to Caution - user should verify
954                        safe_to_delete: SafetyLevel::Caution,
955                        clean_command: Some("rm -rf ~/.asdf".to_string()),
956                    });
957                }
958            }
959        }
960
961        // Check volta
962        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        // Check for Homebrew Node alongside version manager Node
991        self.check_homebrew_node_duplicate(&mut items)?;
992
993        // Check for Homebrew Python alongside pyenv
994        self.check_homebrew_python_duplicate(&mut items)?;
995
996        // Detect old Homebrew Cellar versions
997        self.detect_homebrew_old_versions(&mut items)?;
998
999        // Detect uv cache and tools
1000        self.detect_uv_cache(&mut items)?;
1001
1002        Ok(items)
1003    }
1004
1005    /// Check if Homebrew Python is installed alongside pyenv
1006    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        // Check for Homebrew Python versions
1017        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        // Look for python@3.x packages
1024        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    /// Detect old Homebrew Cellar versions (keeps only latest, flags older ones)
1054    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        // Packages to check for old versions
1064        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            // Get all versions
1093            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; // Only one version, nothing to clean
1105            }
1106
1107            // Sort by modification time (newest last)
1108            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            // Flag all but the latest version
1115            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                    // 5MB minimum
1122                    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    /// Detect uv cache and tools
1143    fn detect_uv_cache(&self, items: &mut Vec<CleanableItem>) -> Result<()> {
1144        // uv cache location
1145        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                // 100MB minimum
1150                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        // uv managed Python versions
1167        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    /// Check if Homebrew Node is installed alongside a version manager
1204    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            // Check if there's also a version manager node
1220            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                // There's a duplicate
1227                let manager = if has_fnm {
1228                    "fnm"
1229                } else if has_nvm {
1230                    "nvm"
1231                } else {
1232                    "Volta"
1233                };
1234
1235                // Get Homebrew node cellar path for size calculation
1236                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    /// Detect stale configurations in shell rc files
1266    ///
1267    /// SAFETY: These items use a non-existent marker path to prevent accidental
1268    /// deletion of shell rc files. Users must manually edit their config files.
1269    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                // Check for NVM config without installation
1286                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                        // SAFETY: Use non-existent path to prevent accidental deletion of rc file
1298                        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                // Check for pyenv config without installation
1312                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                        // SAFETY: Use non-existent path to prevent accidental deletion of rc file
1324                        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                // Check for rbenv config without installation
1338                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                        // SAFETY: Use non-existent path to prevent accidental deletion of rc file
1350                        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    /// Convert analysis results to cleanable items for TUI display
1369    pub fn to_cleanable_items(&self, result: &BinaryAnalysisResult) -> Vec<CleanableItem> {
1370        let mut items = Vec::new();
1371
1372        // Add duplicate groups
1373        for group in &result.duplicates {
1374            // Skip if only system binary or all are active
1375            if group.instances.iter().all(|i| i.is_active || i.source == BinarySource::System) {
1376                continue;
1377            }
1378
1379            // Find non-active instances that could be removed
1380            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                // Get directory size for version manager installs
1413                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                // Leak the description string to get a static reference
1424                // This is safe because we're in a controlled context
1425                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        // Add unused managers
1449        items.extend(result.unused_managers.clone());
1450
1451        // Add stale configs
1452        items.extend(result.stale_configs.clone());
1453
1454        // Sort by size
1455        items.sort_by(|a, b| b.size.cmp(&a.size));
1456
1457        items
1458    }
1459
1460    /// Get the installation directory for a version manager install
1461    fn get_version_install_dir(&self, binary_path: &Path) -> Option<PathBuf> {
1462        let path_str = binary_path.to_string_lossy();
1463
1464        // fnm: ~/.local/share/fnm/node-versions/v22.22.0/
1465        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        // nvm: ~/.nvm/versions/node/v22.22.0/
1478        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        // pyenv: ~/.pyenv/versions/3.12.0/
1487        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        // Homebrew Cellar: /opt/homebrew/Cellar/node/22.0.0/
1496        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    /// Get clean command for a binary instance
1518    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    /// Get icon for a command
1548    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        // System paths
1591        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        // Homebrew paths
1601        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        // Cargo
1611        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        // Test with a known symlink
1624        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}