Skip to main content

broom/
toolchains_command.rs

1use anyhow::Result;
2use runemark::{
3    ColorMode, Console, Finding, FindingGroup, Location, NextStep, Report, Tone, Verdict,
4};
5use std::collections::HashSet;
6use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use walkdir::WalkDir;
10
11use crate::report::OutputFormat;
12
13pub fn run_toolchains(
14    root_path: &Path,
15    output_format: OutputFormat,
16    color_mode: ColorMode,
17    out: &mut dyn Write,
18) -> Result<()> {
19    let rustup_home = std::env::var_os("RUSTUP_HOME")
20        .map(PathBuf::from)
21        .or_else(|| dirs_home().map(|h| h.join(".rustup")));
22
23    let toolchains_dir = match rustup_home {
24        Some(home) => home.join("toolchains"),
25        None => {
26            anyhow::bail!("Could not determine $RUSTUP_HOME or $HOME directory");
27        }
28    };
29
30    if !toolchains_dir.exists() {
31        if output_format == OutputFormat::Tty {
32            let console = Console::new(color_mode, true);
33            let report = Report::new("cargo-broom toolchains", Verdict::Skipped);
34            writeln!(out, "{}", report.render(console))?;
35        }
36        return Ok(());
37    }
38
39    // Step 1: List installed toolchains in ~/.rustup/toolchains
40    let mut installed_toolchains = Vec::new();
41    if let Ok(entries) = fs::read_dir(&toolchains_dir) {
42        for entry in entries.filter_map(|e| e.ok()) {
43            let path = entry.path();
44            if path.is_dir()
45                && let Some(name) = path.file_name().and_then(|n| n.to_str())
46            {
47                installed_toolchains.push((name.to_string(), path));
48            }
49        }
50    }
51
52    // Step 2: Collect referenced toolchain names from rust-toolchain[.toml] files under root_path
53    let referenced_toolchains = collect_referenced_toolchains(root_path);
54
55    // Step 3: Find unreferenced toolchains
56    let mut unreferenced = Vec::new();
57    for (name, path) in &installed_toolchains {
58        let is_referenced = referenced_toolchains
59            .iter()
60            .any(|ref_name| name.contains(ref_name) || ref_name.contains(name));
61        if !is_referenced {
62            unreferenced.push((name.clone(), path.clone()));
63        }
64    }
65
66    if output_format == OutputFormat::Json {
67        let json_report = serde_json::json!({
68            "root_path": root_path,
69            "installed_toolchains_count": installed_toolchains.len(),
70            "unreferenced_toolchains": unreferenced.iter().map(|(n, _)| n).collect::<Vec<_>>(),
71        });
72        writeln!(out, "{}", serde_json::to_string_pretty(&json_report)?)?;
73        return Ok(());
74    }
75
76    let console = Console::new(color_mode, true);
77    let title = console.paint(Tone::Title, "cargo-broom Toolchain Inspection");
78    writeln!(out, "{}", title)?;
79
80    let verdict = if unreferenced.is_empty() {
81        Verdict::Passed
82    } else {
83        Verdict::Info
84    };
85
86    let mut report = Report::new("cargo-broom toolchains", verdict);
87
88    if !unreferenced.is_empty() {
89        let mut group = FindingGroup::new("Unreferenced rustup Toolchains");
90        for (name, path) in &unreferenced {
91            let finding = Finding::new(
92                Tone::Info,
93                format!(
94                    "Toolchain `{}` is not referenced by any local project",
95                    name
96                ),
97            )
98            .with_location(Location::Artifact(path.clone()));
99            group = group.add_finding(finding);
100        }
101        report = report.add_group(group);
102
103        let step = NextStep::new("Uninstall unneeded toolchains using rustup")
104            .with_command(format!("rustup toolchain uninstall {}", unreferenced[0].0));
105        report = report.add_next_step(step);
106    }
107
108    writeln!(out, "{}", report.render(console))?;
109    Ok(())
110}
111
112fn collect_referenced_toolchains(root: &Path) -> HashSet<String> {
113    let mut ref_set = HashSet::new();
114    for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
115        let name = entry.file_name().to_string_lossy();
116        if (name == "rust-toolchain" || name == "rust-toolchain.toml")
117            && let Ok(content) = fs::read_to_string(entry.path())
118        {
119            if let Ok(toml_val) = toml::from_str::<toml::Value>(&content) {
120                if let Some(channel) = toml_val
121                    .get("toolchain")
122                    .and_then(|t| t.get("channel"))
123                    .and_then(|c| c.as_str())
124                {
125                    ref_set.insert(channel.to_string());
126                }
127            } else {
128                ref_set.insert(content.trim().to_string());
129            }
130        }
131    }
132    ref_set
133}
134
135fn dirs_home() -> Option<PathBuf> {
136    std::env::var_os("HOME").map(PathBuf::from)
137}