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}