Skip to main content

scv_tools/
project_environment.rs

1//! Project-aware Rust preflight shared by delegated agents and the CLI doctor.
2//! Resolves only installed tools; does not install, update, or edit rustup state.
3
4mod 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/// The environment additions and human-readable result of a launch preflight.
20#[derive(Debug, Default)]
21pub struct ProjectEnvironment {
22    pub(crate) environment: Vec<(OsString, OsString)>,
23    /// Paths and versions only; no credentials or arbitrary environment dump.
24    pub diagnostics: Vec<String>,
25}
26
27/// Resolve the Rust tools for `cwd` before relocating an agent's home.
28/// `environment` is its adapter environment (including its private HOME).
29/// Non-Rust projects return no changes and do not need Rust installed.
30pub 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            // Join the probe and its process group before returning.
48            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    // Tool installations belong to the launching user. Cargo's mutable state
88    // and registry credentials still belong to the private agent home.
89    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        // `which` is intentionally used without --install. Rustup owns its
139        // override precedence and toolchain-file format, including path tools.
140        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            // A default is not a project pin. Consider installed stable tools,
156            // by measured version, never by lexicographic channel/directory name.
157            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    // Without rustup, retain PATH ordering: the validated system compiler
238    // and Cargo may come from different directories with shadowed tools.
239    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;