Skip to main content

cargo_reclaim/
toolchain_hash.rs

1use std::collections::HashSet;
2use std::error::Error;
3use std::fmt;
4use std::hash::{Hash, Hasher};
5use std::process::Command;
6
7use crate::planner::PlannerOptions;
8use rustc_stable_hash::StableSipHasher128;
9
10pub type ToolchainHashResult<T> = Result<T, ToolchainHashError>;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum ToolchainHashError {
14    RustupToolchainListFailed {
15        status: Option<i32>,
16        stderr: String,
17    },
18    RustupRunRustcFailed {
19        toolchain: String,
20        status: Option<i32>,
21        stderr: String,
22    },
23    EmptyRustcVersion {
24        toolchain: String,
25    },
26    InvalidInstalledToolchainLine {
27        line: String,
28    },
29    Io {
30        command: String,
31        message: String,
32    },
33    Utf8 {
34        command: String,
35        message: String,
36    },
37}
38
39impl fmt::Display for ToolchainHashError {
40    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::RustupToolchainListFailed { status, stderr } => write!(
43                formatter,
44                "rustup toolchain list failed with status {}: {}",
45                status_label(*status),
46                stderr.trim()
47            ),
48            Self::RustupRunRustcFailed {
49                toolchain,
50                status,
51                stderr,
52            } => write!(
53                formatter,
54                "rustup run {toolchain} rustc -vV failed with status {}: {}",
55                status_label(*status),
56                stderr.trim()
57            ),
58            Self::EmptyRustcVersion { toolchain } => write!(
59                formatter,
60                "rustup run {toolchain} rustc -vV succeeded but did not emit version details"
61            ),
62            Self::InvalidInstalledToolchainLine { line } => write!(
63                formatter,
64                "rustup toolchain list reported an invalid installed toolchain line `{line}`"
65            ),
66            Self::Io { command, message } => {
67                write!(formatter, "failed to run {command}: {message}")
68            }
69            Self::Utf8 { command, message } => {
70                write!(formatter, "{command} emitted invalid UTF-8: {message}")
71            }
72        }
73    }
74}
75
76impl Error for ToolchainHashError {}
77
78pub trait ToolchainHashResolver {
79    fn installed_toolchains(&self) -> ToolchainHashResult<Vec<String>>;
80    fn toolchain_rustc_hash(&self, toolchain: &str) -> ToolchainHashResult<u64>;
81}
82
83#[derive(Debug, Clone, Copy, Default)]
84struct CommandToolchainHashResolver;
85
86impl ToolchainHashResolver for CommandToolchainHashResolver {
87    fn installed_toolchains(&self) -> ToolchainHashResult<Vec<String>> {
88        let output = Command::new("rustup")
89            .arg("toolchain")
90            .arg("list")
91            .output()
92            .map_err(|source| ToolchainHashError::Io {
93                command: "rustup toolchain list".to_string(),
94                message: source.to_string(),
95            })?;
96        if !output.status.success() {
97            return Err(ToolchainHashError::RustupToolchainListFailed {
98                status: output.status.code(),
99                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
100            });
101        }
102        let stdout =
103            String::from_utf8(output.stdout).map_err(|source| ToolchainHashError::Utf8 {
104                command: "rustup toolchain list".to_string(),
105                message: source.to_string(),
106            })?;
107        parse_installed_toolchains(&stdout)
108    }
109
110    fn toolchain_rustc_hash(&self, toolchain: &str) -> ToolchainHashResult<u64> {
111        let output = Command::new("rustup")
112            .arg("run")
113            .arg(toolchain)
114            .arg("rustc")
115            .arg("-vV")
116            .output()
117            .map_err(|source| ToolchainHashError::Io {
118                command: format!("rustup run {toolchain} rustc -vV"),
119                message: source.to_string(),
120            })?;
121        if !output.status.success() {
122            return Err(ToolchainHashError::RustupRunRustcFailed {
123                toolchain: toolchain.to_string(),
124                status: output.status.code(),
125                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
126            });
127        }
128        let stdout =
129            String::from_utf8(output.stdout).map_err(|source| ToolchainHashError::Utf8 {
130                command: format!("rustup run {toolchain} rustc -vV"),
131                message: source.to_string(),
132            })?;
133        rustc_version_hash(toolchain, &stdout)
134    }
135}
136
137pub fn resolve_toolchain_hash_options(
138    options: &mut PlannerOptions,
139    resolver: &impl ToolchainHashResolver,
140) -> ToolchainHashResult<()> {
141    if !options.keep_installed_toolchains && options.keep_toolchains.is_empty() {
142        return Ok(());
143    }
144
145    let installed_toolchains = if options.keep_installed_toolchains {
146        Some(resolver.installed_toolchains()?)
147    } else {
148        None
149    };
150    let mut seen_toolchains: HashSet<&str> = HashSet::new();
151    let mut seen_hashes = options
152        .keep_rustc_hashes
153        .iter()
154        .copied()
155        .collect::<HashSet<_>>();
156    for toolchain in options.keep_toolchains.iter().map(String::as_str).chain(
157        installed_toolchains
158            .iter()
159            .flat_map(|toolchains| toolchains.iter().map(String::as_str)),
160    ) {
161        if !seen_toolchains.insert(toolchain) {
162            continue;
163        }
164        let rustc_hash = resolver.toolchain_rustc_hash(toolchain)?;
165        if seen_hashes.insert(rustc_hash) {
166            options.keep_rustc_hashes.push(rustc_hash);
167        }
168    }
169
170    Ok(())
171}
172
173pub fn resolve_command_toolchain_hash_options(
174    options: &mut PlannerOptions,
175) -> ToolchainHashResult<()> {
176    resolve_toolchain_hash_options(options, &CommandToolchainHashResolver)
177}
178
179fn parse_installed_toolchains(stdout: &str) -> ToolchainHashResult<Vec<String>> {
180    let mut toolchains = Vec::new();
181    for line in stdout
182        .lines()
183        .map(str::trim)
184        .filter(|line| !line.is_empty())
185    {
186        let mut fields = line.split_whitespace();
187        let Some(name) = fields.next() else {
188            continue;
189        };
190        if name.starts_with('(') {
191            return Err(ToolchainHashError::InvalidInstalledToolchainLine {
192                line: line.to_string(),
193            });
194        }
195        toolchains.push(name.to_string());
196    }
197    Ok(toolchains)
198}
199
200fn rustc_version_hash(toolchain: &str, stdout: &str) -> ToolchainHashResult<u64> {
201    if stdout.trim().is_empty() {
202        return Err(ToolchainHashError::EmptyRustcVersion {
203            toolchain: toolchain.to_string(),
204        });
205    }
206    let mut hasher = StableSipHasher128::new();
207    stdout.hash(&mut hasher);
208    Ok(Hasher::finish(&hasher))
209}
210
211fn status_label(status: Option<i32>) -> String {
212    status
213        .map(|status| status.to_string())
214        .unwrap_or_else(|| "unknown".to_string())
215}
216
217#[cfg(test)]
218mod tests {
219    use std::collections::HashMap;
220
221    use super::*;
222
223    #[derive(Default)]
224    struct FakeResolver {
225        installed: Vec<String>,
226        hashes: HashMap<String, u64>,
227    }
228
229    impl ToolchainHashResolver for FakeResolver {
230        fn installed_toolchains(&self) -> ToolchainHashResult<Vec<String>> {
231            Ok(self.installed.clone())
232        }
233
234        fn toolchain_rustc_hash(&self, toolchain: &str) -> ToolchainHashResult<u64> {
235            self.hashes.get(toolchain).copied().ok_or_else(|| {
236                ToolchainHashError::EmptyRustcVersion {
237                    toolchain: toolchain.to_string(),
238                }
239            })
240        }
241    }
242
243    #[test]
244    fn parses_installed_toolchain_names_without_status_annotations() -> Result<(), Box<dyn Error>> {
245        let toolchains = parse_installed_toolchains(
246            "stable-x86_64-unknown-linux-gnu (default)\nnightly-x86_64-unknown-linux-gnu\n",
247        )?;
248
249        assert_eq!(
250            toolchains,
251            [
252                "stable-x86_64-unknown-linux-gnu",
253                "nightly-x86_64-unknown-linux-gnu"
254            ]
255        );
256        Ok(())
257    }
258
259    #[test]
260    fn hashes_rustc_verbose_version_output() -> Result<(), Box<dyn Error>> {
261        assert_eq!(
262            rustc_version_hash("stable", "rustc 1.90.0\nhost: x86_64-unknown-linux-gnu\n")?,
263            rustc_version_hash("stable", "rustc 1.90.0\nhost: x86_64-unknown-linux-gnu\n")?
264        );
265        assert_ne!(
266            rustc_version_hash("stable", "rustc 1.90.0\nhost: x86_64-unknown-linux-gnu\n")?,
267            rustc_version_hash("nightly", "rustc 1.91.0\nhost: x86_64-unknown-linux-gnu\n")?
268        );
269        Ok(())
270    }
271
272    #[test]
273    fn rejects_empty_rustc_verbose_version_output() {
274        assert!(matches!(
275            rustc_version_hash("stable", ""),
276            Err(ToolchainHashError::EmptyRustcVersion { toolchain }) if toolchain == "stable"
277        ));
278    }
279
280    #[test]
281    fn resolves_named_and_installed_toolchains_into_existing_hashes() -> Result<(), Box<dyn Error>>
282    {
283        let mut resolver = FakeResolver {
284            installed: vec!["stable".to_string(), "beta".to_string()],
285            hashes: HashMap::new(),
286        };
287        resolver.hashes.insert("stable".to_string(), 7);
288        resolver.hashes.insert("beta".to_string(), 8);
289        resolver.hashes.insert("nightly".to_string(), 7);
290        let mut options = PlannerOptions {
291            keep_rustc_hashes: vec![3],
292            keep_installed_toolchains: true,
293            keep_toolchains: vec!["nightly".to_string()],
294            ..PlannerOptions::default()
295        };
296
297        resolve_toolchain_hash_options(&mut options, &resolver)?;
298
299        assert_eq!(options.keep_rustc_hashes, [3, 7, 8]);
300        Ok(())
301    }
302}