use std::{ collections::BTreeMap, env, ffi::OsString, io, path::{Path, PathBuf}, }; use serde::{Deserialize, Serialize}; use tokio::process::Command; use utoipa::ToSchema; const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"]; #[derive(Debug, Clone)] pub struct PreparedPython { pub interpreter: PathBuf, pub virtual_env: Option, environment: BTreeMap, remove_environment: Vec<&'static str>, } impl PreparedPython { pub fn apply(&self, command: &mut Command) { for key in &self.remove_environment { command.env_remove(key); } command.envs(&self.environment); } } pub fn prepare_script_environment( working_dir: &str, interpreter: &str, configured_venv: Option<&str>, user_env: &BTreeMap, ) -> io::Result { if !is_python_interpreter(interpreter) { return Ok(PreparedPython { interpreter: resolve_interpreter(working_dir, interpreter), virtual_env: None, environment: user_env.clone(), remove_environment: Vec::new(), }); } let interpreter_path = resolve_interpreter(working_dir, interpreter); let detected_venv = resolve_venv(working_dir, interpreter, configured_venv)?.venv; let mut environment = BTreeMap::new(); for (key, value) in user_env { if key.eq_ignore_ascii_case("PATH") || key.eq_ignore_ascii_case("VIRTUAL_ENV") || key.eq_ignore_ascii_case("VIRTUAL_ENV_PROMPT") || (detected_venv.is_some() && key.eq_ignore_ascii_case("PYTHONHOME")) { continue; } environment.insert(key.clone(), value.clone()); } if let Some(venv) = &detected_venv { let scripts_dir = venv.join("Scripts"); let python = scripts_dir.join("python.exe"); environment.insert( "VIRTUAL_ENV".to_owned(), venv.to_string_lossy().into_owned(), ); environment.insert( "VIRTUAL_ENV_PROMPT".to_owned(), venv.file_name() .unwrap_or_default() .to_string_lossy() .into_owned(), ); environment.insert( "PATH".to_owned(), build_venv_path(&scripts_dir, user_env)? .to_string_lossy() .into_owned(), ); environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned()); return Ok(PreparedPython { interpreter: python, virtual_env: Some(venv.clone()), environment, remove_environment: vec![ "PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV", "VIRTUAL_ENV_PROMPT", ], }); } environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned()); Ok(PreparedPython { interpreter: interpreter_path, virtual_env: None, environment, remove_environment: vec!["VIRTUAL_ENV", "VIRTUAL_ENV_PROMPT"], }) } /// 虚拟环境的来源,用于在界面上说明"为什么"关联到了这个环境。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum VenvSource { /// 手动填写的 `action.venv` Configured, /// 由解释器路径(`...\.venv\Scripts\python.exe`)识别 Interpreter, /// 从工作目录或上级目录探测到的 `.venv`、`venv` 或 `env` WorkingDirectory, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedVenv { pub venv: Option, pub source: Option, } /// 解析任务实际会使用的虚拟环境,顺序与执行期保持一致: /// 手动指定 → 解释器路径推断 → 工作目录向上探测。 pub fn resolve_venv( working_dir: &str, interpreter: &str, configured_venv: Option<&str>, ) -> io::Result { if !is_python_interpreter(interpreter) { return Ok(ResolvedVenv { venv: None, source: None, }); } let working_directory = absolute_path(Path::new(working_dir))?; let interpreter_path = resolve_interpreter(working_dir, interpreter); let explicit_venv = configured_venv .filter(|value| !value.trim().is_empty()) .map(|value| resolve_against(&working_directory, value)) .transpose()?; if let Some(venv) = explicit_venv .as_deref() .map(validate_venv) .transpose()? .flatten() { return Ok(ResolvedVenv { venv: Some(venv), source: Some(VenvSource::Configured), }); } if let Some(venv) = detect_venv_from_interpreter(&interpreter_path) { return Ok(ResolvedVenv { venv: Some(venv), source: Some(VenvSource::Interpreter), }); } if is_generic_python(interpreter) && let Some(venv) = detect_venv_from_ancestors(&working_directory) { return Ok(ResolvedVenv { venv: Some(venv), source: Some(VenvSource::WorkingDirectory), }); } Ok(ResolvedVenv { venv: None, source: None, }) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum InterpreterKind { Python, Node, Other, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum VenvProbeStatus { /// 已关联到有效的虚拟环境 Linked, /// 手动指定的虚拟环境无效 Invalid, /// 没有虚拟环境,将直接使用系统解释器 Missing, /// 解释器不是 Python,虚拟环境不适用 NotApplicable, /// 输入不完整,暂时无法探测 Incomplete, } /// 编辑器在用户填写工作目录时发起的探测请求。 #[derive(Debug, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] pub struct VenvProbeRequest { /// 任务工作目录,对应 `action.working_dir` pub working_dir: String, /// 解释器或可执行程序,对应 `action.interpreter` pub interpreter: String, /// 手动填写的虚拟环境目录,对应 `action.venv` #[serde(default)] pub venv: Option, } #[derive(Debug, Serialize, ToSchema)] pub struct VenvProbeResponse { pub status: VenvProbeStatus, pub interpreter_kind: InterpreterKind, /// 虚拟环境根目录的绝对路径 pub venv_path: Option, /// 供界面展示的解释器路径;位于工作目录内时使用相对路径 pub display_path: Option, pub source: Option, pub message: String, } pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse { let working_dir = request.working_dir.trim(); let interpreter = request.interpreter.trim(); let configured_venv = request .venv .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); let interpreter_kind = classify_interpreter(interpreter); if working_dir.is_empty() { return incomplete_probe(interpreter_kind, "请先填写工作目录"); } if interpreter.is_empty() { return incomplete_probe(interpreter_kind, "请先填写解释器或可执行程序"); } if !matches!(interpreter_kind, InterpreterKind::Python) { return VenvProbeResponse { status: VenvProbeStatus::NotApplicable, interpreter_kind, venv_path: None, display_path: Some(interpreter.to_owned()), source: None, message: "当前解释器不是 Python,不需要虚拟环境".to_owned(), }; } let working_directory = match absolute_path(Path::new(working_dir)) { Ok(path) => path, Err(error) => { return incomplete_probe(interpreter_kind, &format!("工作目录无效: {error}")); } }; if !working_directory.is_dir() { return incomplete_probe(interpreter_kind, "工作目录不存在,暂时无法探测虚拟环境"); } match resolve_venv(working_dir, interpreter, configured_venv) { Ok(ResolvedVenv { venv: Some(venv), source, }) => VenvProbeResponse { status: VenvProbeStatus::Linked, interpreter_kind, display_path: Some(python_display_path(&venv, working_dir)), venv_path: Some(venv.to_string_lossy().into_owned()), source, message: venv_source_message(source), }, Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse { status: VenvProbeStatus::Missing, interpreter_kind, venv_path: None, display_path: Some(interpreter.to_owned()), source: None, message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(), }, Err(error) => VenvProbeResponse { status: VenvProbeStatus::Invalid, interpreter_kind, venv_path: None, display_path: None, source: None, message: error.to_string(), }, } } fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvProbeResponse { VenvProbeResponse { status: VenvProbeStatus::Incomplete, interpreter_kind, venv_path: None, display_path: None, source: None, message: message.to_owned(), } } fn venv_source_message(source: Option) -> String { match source { Some(VenvSource::Configured) => "已关联手动指定的虚拟环境".to_owned(), Some(VenvSource::Interpreter) => "已从解释器路径识别虚拟环境".to_owned(), Some(VenvSource::WorkingDirectory) => "已自动关联工作目录中的虚拟环境".to_owned(), None => "已关联虚拟环境".to_owned(), } } fn python_display_path(venv: &Path, working_dir: &str) -> String { let python = venv.join("Scripts").join("python.exe"); absolute_path(Path::new(working_dir)) .ok() .and_then(|base| relative_display(&base, &python)) .unwrap_or_else(|| python.to_string_lossy().into_owned()) } fn relative_display(base: &Path, target: &Path) -> Option { let base_components = base.components().collect::>(); let target_components = target.components().collect::>(); if target_components.len() <= base_components.len() { return None; } let shares_prefix = base_components .iter() .zip(&target_components) .all(|(base, target)| { base.as_os_str() .to_string_lossy() .eq_ignore_ascii_case(&target.as_os_str().to_string_lossy()) }); if !shares_prefix { return None; } let relative = target_components[base_components.len()..] .iter() .map(|component| component.as_os_str()) .collect::(); Some(relative.to_string_lossy().into_owned()) } fn classify_interpreter(interpreter: &str) -> InterpreterKind { if is_python_interpreter(interpreter) { return InterpreterKind::Python; } let name = Path::new(interpreter) .file_name() .and_then(|name| name.to_str()) .unwrap_or(interpreter) .to_ascii_lowercase(); let stem = name.strip_suffix(".exe").unwrap_or(&name); if matches!(stem, "node" | "nodejs") { InterpreterKind::Node } else { InterpreterKind::Other } } pub fn validate_configured_venv(working_dir: &str, configured_venv: &str) -> io::Result { let working_directory = absolute_path(Path::new(working_dir))?; let venv = resolve_against(&working_directory, configured_venv)?; validate_venv(&venv)?.ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidInput, format!("无效的 Python 虚拟环境: {}", venv.display()), ) }) } fn validate_venv(path: &Path) -> io::Result> { let root = absolute_path(path)?; let python = root.join("Scripts").join("python.exe"); if !root.join("pyvenv.cfg").is_file() { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("虚拟环境缺少 pyvenv.cfg: {}", root.display()), )); } if !python.is_file() { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("虚拟环境缺少 Scripts\\python.exe: {}", root.display()), )); } Ok(Some(root)) } fn detect_venv_from_ancestors(start: &Path) -> Option { for directory in start.ancestors() { for name in VENV_DIRECTORY_NAMES { let candidate = directory.join(name); if candidate.join("pyvenv.cfg").is_file() && candidate.join("Scripts").join("python.exe").is_file() { return Some(candidate); } } } None } fn detect_venv_from_interpreter(interpreter: &Path) -> Option { let scripts = interpreter.parent()?; if !scripts .file_name() .is_some_and(|name| name.eq_ignore_ascii_case("Scripts")) { return None; } let root = scripts.parent()?; (root.join("pyvenv.cfg").is_file() && scripts.join("python.exe").is_file()) .then(|| root.to_path_buf()) } fn build_venv_path( scripts_dir: &Path, user_env: &BTreeMap, ) -> io::Result { let base = user_env .iter() .find(|(key, _)| key.eq_ignore_ascii_case("PATH")) .map(|(_, value)| OsString::from(value)) .or_else(|| env::var_os("PATH")) .unwrap_or_default(); let mut seen = std::collections::HashSet::new(); let mut paths = Vec::new(); for path in std::iter::once(scripts_dir.to_path_buf()).chain(env::split_paths(&base)) { let key = path.to_string_lossy().to_ascii_lowercase(); if seen.insert(key) { paths.push(path); } } env::join_paths(paths).map_err(io::Error::other) } fn resolve_interpreter(working_dir: &str, interpreter: &str) -> PathBuf { let path = Path::new(interpreter); if path.is_absolute() || path.components().count() == 1 { path.to_path_buf() } else { Path::new(working_dir).join(path) } } fn resolve_against(base: &Path, value: &str) -> io::Result { let path = Path::new(value); let resolved = if path.is_absolute() { path.to_path_buf() } else { base.join(path) }; absolute_path(&resolved) } fn absolute_path(path: &Path) -> io::Result { std::path::absolute(path) } fn is_python_interpreter(interpreter: &str) -> bool { let name = Path::new(interpreter) .file_name() .and_then(|name| name.to_str()) .unwrap_or(interpreter) .to_ascii_lowercase(); name == "py.exe" || name == "py" || name.starts_with("python") } fn is_generic_python(interpreter: &str) -> bool { Path::new(interpreter).components().count() == 1 && is_python_interpreter(interpreter) } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; fn create_fake_venv(root: &Path) -> PathBuf { let venv = root.join(".venv"); fs::create_dir_all(venv.join("Scripts")).unwrap(); fs::write(venv.join("pyvenv.cfg"), "home = C:\\Python").unwrap(); fs::write(venv.join("Scripts").join("python.exe"), "").unwrap(); venv } #[test] fn detects_venv_from_working_directory_and_builds_path() { let temp = TempDir::new().unwrap(); let venv = create_fake_venv(temp.path()); let working_dir = temp.path().join("project").join("src"); fs::create_dir_all(&working_dir).unwrap(); let mut user_env = BTreeMap::new(); user_env.insert("CUSTOM_VALUE".to_owned(), "yes".to_owned()); user_env.insert("PYTHONHOME".to_owned(), "C:\\wrong".to_owned()); let prepared = prepare_script_environment( working_dir.to_str().unwrap(), "python.exe", None, &user_env, ) .unwrap(); assert_eq!(prepared.virtual_env, Some(venv.clone())); assert_eq!( prepared.interpreter, venv.join("Scripts").join("python.exe") ); assert_eq!( prepared.environment.get("VIRTUAL_ENV"), Some(&venv.to_string_lossy().into_owned()) ); assert_eq!( prepared.environment.get("PYTHONUNBUFFERED"), Some(&"1".to_owned()) ); } #[test] fn explicit_venv_is_validated() { let temp = TempDir::new().unwrap(); let venv = create_fake_venv(temp.path()); let prepared = prepare_script_environment( temp.path().to_str().unwrap(), "python.exe", Some(venv.to_str().unwrap()), &BTreeMap::new(), ) .unwrap(); assert_eq!(prepared.virtual_env, Some(venv)); } #[test] fn invalid_explicit_venv_returns_error() { let temp = TempDir::new().unwrap(); let missing = temp.path().join("missing-venv"); let error = prepare_script_environment( temp.path().to_str().unwrap(), "python.exe", Some(missing.to_str().unwrap()), &BTreeMap::new(), ) .unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::InvalidInput); } #[test] fn non_python_interpreter_is_not_modified() { let temp = TempDir::new().unwrap(); create_fake_venv(temp.path()); let prepared = prepare_script_environment( temp.path().to_str().unwrap(), "node.exe", None, &BTreeMap::new(), ) .unwrap(); assert_eq!(prepared.virtual_env, None); assert!(prepared.environment.is_empty()); } #[test] fn venv_environment_overrides_conflicting_python_variables() { let temp = TempDir::new().unwrap(); let venv = create_fake_venv(temp.path()); let mut user_env = BTreeMap::new(); user_env.insert( "Path".to_owned(), temp.path() .join("custom-bin") .to_string_lossy() .into_owned(), ); user_env.insert("PYTHONHOME".to_owned(), "C:\\wrong".to_owned()); user_env.insert("PYTHONUNBUFFERED".to_owned(), "0".to_owned()); user_env.insert( "PYTHONPATH".to_owned(), "C:\\explicit-python-path".to_owned(), ); let prepared = prepare_script_environment( temp.path().to_str().unwrap(), "python.exe", None, &user_env, ) .unwrap(); assert!(!prepared.environment.contains_key("PYTHONHOME")); assert_eq!( prepared.environment.get("PYTHONUNBUFFERED"), Some(&"1".to_owned()) ); assert_eq!( prepared.environment.get("PYTHONPATH"), Some(&"C:\\explicit-python-path".to_owned()) ); let paths = env::split_paths( prepared .environment .get("PATH") .expect("venv PATH should be present"), ) .collect::>(); assert_eq!(paths.first(), Some(&venv.join("Scripts"))); assert!(paths.contains(&temp.path().join("custom-bin"))); } #[test] fn global_python_removes_stale_virtual_env_markers() { let temp = TempDir::new().unwrap(); let prepared = prepare_script_environment( temp.path().to_str().unwrap(), "python.exe", None, &BTreeMap::new(), ) .unwrap(); assert_eq!(prepared.virtual_env, None); assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV")); assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV_PROMPT")); } fn probe(working_dir: &str, interpreter: &str, venv: Option<&str>) -> VenvProbeResponse { probe_venv(&VenvProbeRequest { working_dir: working_dir.to_owned(), interpreter: interpreter.to_owned(), venv: venv.map(str::to_owned), }) } #[test] fn probe_links_venv_detected_in_working_directory() { let temp = TempDir::new().unwrap(); create_fake_venv(temp.path()); let response = probe(temp.path().to_str().unwrap(), "python.exe", None); assert_eq!(response.status, VenvProbeStatus::Linked); assert_eq!(response.source, Some(VenvSource::WorkingDirectory)); assert_eq!(response.interpreter_kind, InterpreterKind::Python); let expected = Path::new(".venv") .join("Scripts") .join("python.exe") .to_string_lossy() .into_owned(); assert_eq!(response.display_path, Some(expected)); } #[test] fn probe_prefers_configured_venv_over_detection() { let temp = TempDir::new().unwrap(); let project = temp.path().join("project"); fs::create_dir_all(&project).unwrap(); let venv = create_fake_venv(&project); let response = probe( project.to_str().unwrap(), "python.exe", Some(venv.to_str().unwrap()), ); assert_eq!(response.status, VenvProbeStatus::Linked); assert_eq!(response.source, Some(VenvSource::Configured)); } #[test] fn probe_reports_system_interpreter_when_no_venv_exists() { let temp = TempDir::new().unwrap(); let response = probe(temp.path().to_str().unwrap(), "python.exe", None); assert_eq!(response.status, VenvProbeStatus::Missing); assert_eq!(response.display_path.as_deref(), Some("python.exe")); assert!(response.venv_path.is_none()); } #[test] fn probe_rejects_invalid_configured_venv() { let temp = TempDir::new().unwrap(); let missing = temp.path().join("missing-venv"); let response = probe( temp.path().to_str().unwrap(), "python.exe", Some(missing.to_str().unwrap()), ); assert_eq!(response.status, VenvProbeStatus::Invalid); assert!(response.message.contains("pyvenv.cfg")); } #[test] fn probe_marks_non_python_interpreters_as_not_applicable() { let temp = TempDir::new().unwrap(); let working_dir = temp.path().to_str().unwrap(); let node = probe(working_dir, "node.exe", None); assert_eq!(node.status, VenvProbeStatus::NotApplicable); assert_eq!(node.interpreter_kind, InterpreterKind::Node); let shell = probe(working_dir, "cmd.exe", None); assert_eq!(shell.status, VenvProbeStatus::NotApplicable); assert_eq!(shell.interpreter_kind, InterpreterKind::Other); } #[test] fn probe_requires_existing_working_directory() { let temp = TempDir::new().unwrap(); let empty = probe(" ", "python.exe", None); assert_eq!(empty.status, VenvProbeStatus::Incomplete); assert!(empty.message.contains("工作目录")); let missing = temp.path().join("absent"); let response = probe(missing.to_str().unwrap(), "python.exe", None); assert_eq!(response.status, VenvProbeStatus::Incomplete); assert!(response.message.contains("工作目录")); } #[test] fn probe_matches_execution_environment_for_same_input() { let temp = TempDir::new().unwrap(); let venv = create_fake_venv(temp.path()); let working_dir = temp.path().to_str().unwrap(); let prepared = prepare_script_environment(working_dir, "python.exe", None, &BTreeMap::new()).unwrap(); let response = probe(working_dir, "python.exe", None); assert_eq!(prepared.virtual_env.as_deref(), Some(venv.as_path())); assert_eq!(response.venv_path.as_deref(), Some(venv.to_str().unwrap())); assert_eq!(response.status, VenvProbeStatus::Linked); } }