diff --git a/src/cli/global/expose.rs b/src/cli/global/expose.rs index 4062cd08c..fa97f6e82 100644 --- a/src/cli/global/expose.rs +++ b/src/cli/global/expose.rs @@ -6,6 +6,10 @@ use pixi_config::{Config, ConfigCli}; use crate::global::{self, EnvironmentName, ExposedName}; +/// Add exposed binaries from an environment to your global environment +/// +/// `pixi global expose add python310=python3.10 python3=python3 --environment myenv` +/// will expose the `python3.10` executable as `python310` and the `python3` executable as `python3` #[derive(Parser, Debug)] pub struct AddArgs { /// Add one or more `MAPPING` for environment `ENV` which describe which executables are exposed. @@ -38,6 +42,11 @@ fn parse_mapping(input: &str) -> miette::Result { )) }) } + +/// Remove exposed binaries from the global environment +/// +/// `pixi global expose remove python310 python3 --environment myenv` +/// will remove the exposed names `python310` and `python3` from the environment `myenv` #[derive(Parser, Debug)] pub struct RemoveArgs { /// The exposed names that should be removed @@ -54,6 +63,13 @@ pub struct RemoveArgs { config: ConfigCli, } +/// Interact with the exposure of binaries in the global environment +/// +/// `pixi global expose add python310=python3.10 --environment myenv` +/// will expose the `python3.10` executable as `python310` from the environment `myenv` +/// +/// `pixi global expose remove python310 --environment myenv` +/// will remove the exposed name `python310` from the environment `myenv` #[derive(Parser, Debug)] #[clap(group(clap::ArgGroup::new("command")))] pub enum SubCommand { diff --git a/src/global/common.rs b/src/global/common.rs index 53e096791..dc1864360 100644 --- a/src/global/common.rs +++ b/src/global/common.rs @@ -1,14 +1,13 @@ -use std::{ - io::Read, - path::{Path, PathBuf}, -}; - use super::{EnvironmentName, ExposedName}; use fs_err as fs; use fs_err::tokio as tokio_fs; use miette::IntoDiagnostic; use pixi_config::home_path; use pixi_consts::consts; +use std::{ + io::Read, + path::{Path, PathBuf}, +}; /// Global binaries directory, default to `$HOME/.pixi/bin` #[derive(Debug, Clone)] @@ -180,12 +179,75 @@ pub(crate) fn is_text(file_path: impl AsRef) -> miette::Result { Ok(!is_binary(file_path)?) } +/// Strips known Windows executable extensions from a file name. +pub(crate) fn strip_windows_executable_extension(file_name: String) -> String { + let file_name = file_name.to_lowercase(); + // Attempt to retrieve the PATHEXT environment variable + let extensions_list: Vec = if let Ok(pathext) = std::env::var("PATHEXT") { + pathext.split(';').map(|s| s.to_lowercase()).collect() + } else { + // Fallback to a default list if PATHEXT is not set + tracing::debug!("Could not find 'PATHEXT' variable, using a default list"); + [ + ".COM", ".EXE", ".BAT", ".CMD", ".VBS", ".VBE", ".JS", ".JSE", ".WSF", ".WSH", ".MSC", + ".CPL", + ] + .iter() + .map(|s| s.to_lowercase()) + .collect() + }; + + // Attempt to strip any known Windows executable extension + extensions_list + .iter() + .find_map(|ext| file_name.strip_suffix(ext)) + .map(|f| f.to_string()) + .unwrap_or(file_name) +} + +/// Strips known Unix executable extensions from a file name. +pub(crate) fn strip_unix_executable_extension(file_name: String) -> String { + let file_name = file_name.to_lowercase(); + + // Define a list of common Unix executable extensions + let extensions_list: Vec<&str> = vec![ + ".sh", ".bash", ".zsh", ".csh", ".tcsh", ".ksh", ".fish", ".py", ".pl", ".rb", ".lua", + ".php", ".tcl", ".awk", ".sed", + ]; + + // Attempt to strip any known Unix executable extension + extensions_list + .iter() + .find_map(|&ext| file_name.strip_suffix(ext)) + .map(|f| f.to_string()) + .unwrap_or(file_name) +} + +/// Strips known executable extensions from a file name based on the target operating system. +/// +/// This function acts as a wrapper that calls either `strip_windows_executable_extension` +/// or `strip_unix_executable_extension` depending on the target OS. +pub(crate) fn executable_from_path(path: &Path) -> String { + let file_name = path + .iter() + .last() + .unwrap_or(path.as_os_str()) + .to_string_lossy() + .to_string(); + + if cfg!(target_family = "windows") { + strip_windows_executable_extension(file_name) + } else { + strip_unix_executable_extension(file_name) + } +} + #[cfg(test)] mod tests { use super::*; use fs_err::tokio as tokio_fs; use itertools::Itertools; - + use rstest::rstest; use tempfile::tempdir; #[tokio::test] @@ -246,4 +308,37 @@ mod tests { assert_eq!(remaining_dirs, vec!["env1", "env3", "non-conda-env-dir"]); } + + #[rstest] + #[case::python312_linux("python3.12", "python3.12")] + #[case::python3_linux("python3", "python3")] + #[case::python_linux("python", "python")] + #[case::python3121_linux("python3.12.1", "python3.12.1")] + #[case::bash_script("bash.sh", "bash")] + #[case::zsh59("zsh-5.9", "zsh-5.9")] + #[case::python_312config("python3.12-config", "python3.12-config")] + #[case::python3_config("python3-config", "python3-config")] + #[case::x2to3("2to3", "2to3")] + #[case::x2to3312("2to3-3.12", "2to3-3.12")] + fn test_strip_executable_unix(#[case] path: &str, #[case] expected: &str) { + let path = Path::new(path); + let result = strip_unix_executable_extension(path.to_string_lossy().to_string()); + assert_eq!(result, expected); + } + + #[rstest] + #[case::python_windows("python.exe", "python")] + #[case::python3_windows("python3.exe", "python3")] + #[case::python312_windows("python3.12.exe", "python3.12")] + #[case::bash("bash", "bash")] + #[case::zsh59("zsh-5.9", "zsh-5.9")] + #[case::python_312config("python3.12-config", "python3.12-config")] + #[case::python3_config("python3-config", "python3-config")] + #[case::x2to3("2to3", "2to3")] + #[case::x2to3312("2to3-3.12", "2to3-3.12")] + fn test_strip_executable_windows(#[case] path: &str, #[case] expected: &str) { + let path = Path::new(path); + let result = strip_windows_executable_extension(path.to_string_lossy().to_string()); + assert_eq!(result, expected); + } } diff --git a/src/global/install.rs b/src/global/install.rs index 121493a50..da617d1c8 100644 --- a/src/global/install.rs +++ b/src/global/install.rs @@ -30,7 +30,7 @@ use std::{ use super::{project::ParsedEnvironment, EnvironmentName, ExposedName}; use crate::{ - global::{self, BinDir, EnvDir}, + global::{self, common::executable_from_path, BinDir, EnvDir}, prefix::Prefix, rlimit::try_increase_rlimit_to_sensible, }; @@ -127,6 +127,7 @@ pub(crate) async fn expose_executables( prefix: &Prefix, bin_dir: &BinDir, ) -> miette::Result { + tracing::debug!("Exposing executables for environment '{}'", env_name); // Determine the shell to use for the invocation script let shell: ShellEnum = if cfg!(windows) { rattler_shell::shell::CmdExe.into() @@ -368,10 +369,7 @@ pub(crate) async fn create_executable_scripts( .into_diagnostic()?; } - let executable_name = global_script_path - .file_stem() - .and_then(OsStr::to_str) - .expect("must always have at least a name"); + let executable_name = executable_from_path(global_script_path); match added_or_changed { AddedOrChanged::Unchanged => {} AddedOrChanged::Added => eprintln!( @@ -454,10 +452,7 @@ pub(crate) async fn sync( }) .collect_vec(); for file in project.bin_dir.files().await? { - let file_name = file - .file_stem() - .and_then(OsStr::to_str) - .ok_or_else(|| miette::miette!("Could not get file stem of {}", file.display()))?; + let file_name = executable_from_path(&file); if !exposed_paths.contains(&file) && file_name != "pixi" { tokio_fs::remove_file(&file).await.into_diagnostic()?; updated_env = true; diff --git a/src/global/project/mod.rs b/src/global/project/mod.rs index bc11d8c99..3207fb69a 100644 --- a/src/global/project/mod.rs +++ b/src/global/project/mod.rs @@ -1,6 +1,9 @@ use super::{BinDir, EnvRoot}; use crate::{ - global::{common::is_text, find_executables, EnvDir}, + global::{ + common::{executable_from_path, is_text}, + find_executables, EnvDir, + }, prefix::Prefix, }; pub(crate) use environment::EnvironmentName; @@ -77,19 +80,10 @@ impl ExposedData { /// environment name, platform, channel, and package information, by reading /// the associated `conda-meta` directory. pub async fn from_exposed_path(path: &Path, env_root: &EnvRoot) -> miette::Result { - let exposed = path - .file_stem() - .and_then(OsStr::to_str) - .ok_or_else(|| miette::miette!("Could not get file stem of {}", path.display())) - .and_then(ExposedName::from_str)?; + let exposed = ExposedName::from_str(executable_from_path(path).as_str())?; let executable_path = extract_executable_from_script(path)?; - let executable = executable_path - .file_stem() - .and_then(OsStr::to_str) - .map(String::from) - .ok_or_else(|| miette::miette!("Could not get file stem of {}", path.display()))?; - + let executable = executable_from_path(&executable_path); let env_path = determine_env_path(&executable_path, env_root.path())?; let env_name = env_path .file_name() @@ -191,7 +185,7 @@ async fn package_from_conda_meta( if find_executables(prefix, &prefix_record) .iter() - .any(|exe_path| exe_path.file_stem().and_then(OsStr::to_str) == Some(executable)) + .any(|exe_path| executable_from_path(exe_path) == executable) { let platform = match Platform::from_str( &prefix_record.repodata_record.package_record.subdir, diff --git a/src/prefix.rs b/src/prefix.rs index 771ddf9f4..d86d00b91 100644 --- a/src/prefix.rs +++ b/src/prefix.rs @@ -110,7 +110,7 @@ impl Prefix { /// Processes prefix records (that you can get by using `find_installed_packages`) /// to filter and collect executable files. pub fn find_executables(&self, prefix_packages: &[PrefixRecord]) -> Vec<(String, PathBuf)> { - prefix_packages + let executables = prefix_packages .iter() .flat_map(|record| { record @@ -118,12 +118,15 @@ impl Prefix { .iter() .filter(|relative_path| self.is_executable(relative_path)) .filter_map(|path| { - path.file_stem() + path.iter() + .last() .and_then(OsStr::to_str) .map(|name| (name.to_string(), path.clone())) }) }) - .collect() + .collect(); + tracing::debug!("Found executables: {:?}", executables); + executables } /// Checks if the given relative path points to an executable file.