1mod project;
5
6use std::{
7 ffi::{OsStr, OsString},
8 path::{Path, PathBuf},
9 time::Duration,
10};
11
12use anyhow::{Context as _, Result, bail};
13use tokio::time::Instant;
14use tokio_util::sync::CancellationToken;
15
16use crate::process::{ProcessSpec, execute_process};
17use project::{Project, Version};
18
19#[derive(Debug, Default)]
21pub struct ProjectEnvironment {
22 pub(crate) environment: Vec<(OsString, OsString)>,
23 pub diagnostics: Vec<String>,
25}
26
27pub async fn resolve(
31 cwd: &Path,
32 environment: &[(OsString, OsString)],
33 cancelled: impl std::future::Future<Output = ()>,
34) -> Result<ProjectEnvironment> {
35 let cancellation = CancellationToken::new();
36 let resolution = resolve_from(
37 cwd,
38 environment,
39 std::env::vars_os().collect(),
40 cancellation.clone(),
41 );
42 tokio::pin!(resolution);
43 let result = tokio::select! {
44 result = &mut resolution => result,
45 () = cancelled => {
46 cancellation.cancel();
47 resolution.await
49 }
50 };
51 result
52 .with_context(|| format!("Rust environment preflight for {}; run `scv agents doctor --workspace <project>` in the launch environment", cwd.display()))
53}
54
55async fn resolve_from(
56 cwd: &Path,
57 adapter: &[(OsString, OsString)],
58 inherited: Vec<(OsString, OsString)>,
59 cancellation: CancellationToken,
60) -> Result<ProjectEnvironment> {
61 let cwd = cwd.canonicalize().context("resolve project directory")?;
62 if !cwd.is_dir() {
63 bail!("project path is not a directory");
64 }
65 let project = Project::detect(&cwd)?;
66 if !project.is_rust() {
67 return Ok(ProjectEnvironment {
68 diagnostics: vec!["No Rust project detected; environment unchanged.".into()],
69 ..Default::default()
70 });
71 }
72 let get = |key: &str| {
73 adapter
74 .iter()
75 .rev()
76 .chain(inherited.iter().rev())
77 .find(|(name, _)| name == key)
78 .map(|(_, value)| value.clone())
79 };
80 let host = |key: &str| {
81 inherited
82 .iter()
83 .rev()
84 .find(|(name, _)| name == key)
85 .map(|(_, value)| value.clone())
86 };
87 let cargo_installs = [
90 get("CARGO_HOME").map(PathBuf::from),
91 host("HOME").map(|home| PathBuf::from(home).join(".cargo")),
92 ];
93 let rustup_home = get("RUSTUP_HOME")
94 .map(PathBuf::from)
95 .or_else(|| host("HOME").map(|home| PathBuf::from(home).join(".rustup")));
96 let path = get("PATH").unwrap_or_default();
97 let rustup = cargo_installs
98 .into_iter()
99 .flatten()
100 .map(|home| absolute(&cwd, &home).join("bin/rustup"))
101 .find(|path| executable(path))
102 .or_else(|| which::which_in("rustup", Some(&path), &cwd).ok());
103 let mut environment = Vec::new();
104 if let Some(home) = rustup_home {
105 environment.push(("RUSTUP_HOME".into(), absolute(&cwd, &home).into()));
106 }
107 if let Some(home) = get("HOME") {
108 environment.push((
109 "CARGO_HOME".into(),
110 absolute(&cwd, &PathBuf::from(home)).join(".cargo").into(),
111 ));
112 }
113 environment.push(("RUSTUP_AUTO_INSTALL".into(), "0".into()));
114 let mut probe_environment: Vec<_> = inherited
115 .into_iter()
116 .filter(|(key, _)| !crate::adapters::is_removed_agent_variable(key))
117 .collect();
118 probe_environment.extend(adapter.iter().cloned());
119 probe_environment.extend(environment.iter().cloned());
120 let probe = Probe {
121 cwd: &cwd,
122 environment: probe_environment,
123 cancellation,
124 deadline: Instant::now() + Duration::from_secs(20),
125 };
126 let mut diagnostics = Vec::new();
127 if let Some(file) = &project.toolchain {
128 diagnostics.push(format!("Toolchain file: {}", file.display()));
129 }
130 if let Some(manifest) = &project.manifest {
131 diagnostics.push(format!(
132 "Manifest: {}; minimum Rust {}",
133 manifest.display(),
134 project.minimum
135 ));
136 }
137 let (rustc, cargo, selection) = if let Some(rustup) = &rustup {
138 let rustc = probe.which(rustup, None, "rustc").await?;
141 let cargo = probe.which(rustup, None, "cargo").await?;
142 let active = probe.run(rustup, &["show", "active-toolchain"]).await?;
143 let version = probe.version(&rustc, "rustc").await?;
144 if version >= project.minimum {
145 (rustc, cargo, format!("rustup: {}", active.trim()))
146 } else if project.toolchain.is_some()
147 || get_toolchain(&probe.environment).is_some()
148 || !active.contains("(default)")
149 {
150 bail!(
151 "selected toolchain {active} has Rust {version}, but the project requires {}; install/fix the explicit toolchain selection",
152 project.minimum
153 );
154 } else {
155 let listed = probe.run(rustup, &["toolchain", "list"]).await?;
158 let mut compatible = Vec::new();
159 for line in listed.lines().take(128) {
160 let Some(candidate) = line.split_whitespace().next() else {
161 continue;
162 };
163 if Some(candidate) == active.strip_suffix(" (default)")
164 || !(candidate.starts_with("stable")
165 || candidate.starts_with(|character: char| character.is_ascii_digit()))
166 {
167 continue;
168 }
169 let Ok(candidate_rustc) = probe.which(rustup, Some(candidate), "rustc").await
170 else {
171 continue;
172 };
173 let Ok(version) = probe.version(&candidate_rustc, "rustc").await else {
174 continue;
175 };
176 if version >= project.minimum {
177 compatible.push((version, candidate.to_owned(), candidate_rustc));
178 }
179 }
180 compatible.sort_by(|left, right| right.cmp(left));
181 let mut selected = None;
182 for (_, name, rustc) in compatible {
183 if let Ok(cargo) = probe.which(rustup, Some(&name), "cargo").await {
184 selected = Some((
185 rustc,
186 cargo,
187 format!(
188 "installed compatible rustup toolchain: {name} (default {active} is too old)"
189 ),
190 ));
191 break;
192 }
193 }
194 selected.with_context(|| format!("default Rust {version} is older than required {}; install a compatible toolchain with `rustup toolchain install stable` or the project's pinned version", project.minimum))?
195 }
196 } else {
197 if project.toolchain.is_some() || get_toolchain(&probe.environment).is_some() {
198 bail!(
199 "project/inherited toolchain selection needs rustup; install rustup or expose its bin directory to SCV"
200 );
201 }
202 let rustc = which::which_in("rustc", Some(&path), &cwd)
203 .context("Rust project needs rustc; install a compatible Rust toolchain")?;
204 let cargo = which::which_in("cargo", Some(&path), &cwd)
205 .context("Rust project needs cargo; install a compatible Rust toolchain")?;
206 (rustc, cargo, "system PATH (rustup unavailable)".into())
207 };
208 let rust_version = probe.version(&rustc, "rustc").await?;
209 let cargo_version = probe.version(&cargo, "cargo").await?;
210 if rust_version < project.minimum || cargo_version < project.minimum {
211 bail!(
212 "{selection}: rustc {rust_version}, cargo {cargo_version}; project requires Rust {} or newer. Install a compatible toolchain; SCV will not launch with these obsolete tools",
213 project.minimum
214 );
215 }
216 let mut paths = Vec::new();
217 if let Some(rustup) = &rustup {
218 paths.push(
219 rustc
220 .parent()
221 .context("rustc has no bin directory")?
222 .to_owned(),
223 );
224 paths.push(
225 cargo
226 .parent()
227 .context("cargo has no bin directory")?
228 .to_owned(),
229 );
230 paths.push(
231 rustup
232 .parent()
233 .context("rustup has no bin directory")?
234 .to_owned(),
235 );
236 }
237 paths.extend(std::env::split_paths(&path));
240 let mut unique = Vec::new();
241 for path in paths {
242 if !unique.contains(&path) {
243 unique.push(path);
244 }
245 }
246 environment.push(("PATH".into(), std::env::join_paths(unique)?));
247 environment.push(("RUSTC".into(), rustc.clone().into()));
248 let rustdoc = rustc.with_file_name("rustdoc");
249 if executable(&rustdoc) {
250 environment.push(("RUSTDOC".into(), rustdoc.into()));
251 }
252 diagnostics.push(format!("Selection: {selection}"));
253 diagnostics.push(format!("rustc {rust_version}: {}", rustc.display()));
254 diagnostics.push(format!("cargo {cargo_version}: {}", cargo.display()));
255 for (key, value) in &environment {
256 diagnostics.push(format!(
257 "{}={}",
258 key.to_string_lossy(),
259 value.to_string_lossy()
260 ));
261 }
262 Ok(ProjectEnvironment {
263 environment,
264 diagnostics,
265 })
266}
267
268fn get_toolchain(environment: &[(OsString, OsString)]) -> Option<&OsStr> {
269 environment
270 .iter()
271 .rev()
272 .find(|(key, _)| key == "RUSTUP_TOOLCHAIN")
273 .map(|(_, value)| value.as_os_str())
274 .filter(|value| !value.is_empty())
275}
276
277fn absolute(cwd: &Path, path: &Path) -> PathBuf {
278 if path.is_absolute() {
279 path.to_owned()
280 } else {
281 cwd.join(path)
282 }
283}
284
285fn executable(path: &Path) -> bool {
286 use std::os::unix::fs::PermissionsExt as _;
287 path.metadata()
288 .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
289}
290
291struct Probe<'a> {
292 cwd: &'a Path,
293 environment: Vec<(OsString, OsString)>,
294 cancellation: CancellationToken,
295 deadline: Instant,
296}
297
298impl Probe<'_> {
299 async fn run(&self, executable: &Path, args: &[&str]) -> Result<String> {
300 if self.cancellation.is_cancelled() {
301 bail!("Rust preflight cancelled");
302 }
303 let remaining = self.deadline.saturating_duration_since(Instant::now());
304 if remaining.is_zero() {
305 bail!("Rust preflight exceeded 20 seconds");
306 }
307 let output = execute_process(
308 ProcessSpec {
309 executable: executable.into(),
310 args: args.iter().map(OsString::from).collect(),
311 cwd: self.cwd.to_owned(),
312 environment: self.environment.clone(),
313 clear_environment: true,
314 sanitize_scv_environment: true,
315 timeout: remaining.min(Duration::from_secs(5)),
316 output_limit: 16 * 1024,
317 },
318 self.cancellation.clone(),
319 )
320 .await
321 .map_err(|error| anyhow::anyhow!("{error}"))?;
322 let value: serde_json::Value = serde_json::from_str(&output.content)?;
323 let text = value["output"].as_str().unwrap_or_default().trim();
324 if output.is_error() || output.truncated {
325 bail!(
326 "{} {args:?} failed (exit {}, timed out {}): {text}. Install the required toolchain/components explicitly; preflight never downloads them",
327 executable.display(),
328 value["exit_code"],
329 value["timed_out"]
330 );
331 }
332 Ok(text.to_owned())
333 }
334
335 async fn which(&self, rustup: &Path, toolchain: Option<&str>, binary: &str) -> Result<PathBuf> {
336 let args = match toolchain {
337 Some(toolchain) => vec!["which", "--toolchain", toolchain, binary],
338 None => vec!["which", binary],
339 };
340 let path = PathBuf::from(self.run(rustup, &args).await?);
341 if !path.is_absolute() || !executable(&path) {
342 bail!(
343 "rustup returned an unusable {binary} path: {}",
344 path.display()
345 );
346 }
347 Ok(path)
348 }
349
350 async fn version(&self, executable: &Path, name: &str) -> Result<Version> {
351 let text = self.run(executable, &["--version"]).await?;
352 let mut words = text.split_whitespace();
353 if words.next() != Some(name) {
354 bail!(
355 "{} returned an invalid {name} version",
356 executable.display()
357 );
358 }
359 let version = words
360 .next()
361 .context("missing tool version")?
362 .split('-')
363 .next()
364 .context("missing version number")?;
365 Version::parse(version)
366 }
367}
368
369#[cfg(test)]
370mod tests;