diff --git a/src/cli/global/install.rs b/src/cli/global/install.rs index ce03d89138..41c711521c 100644 --- a/src/cli/global/install.rs +++ b/src/cli/global/install.rs @@ -248,6 +248,12 @@ async fn setup_environment( // Sync completions state_changes |= project.sync_completions(env_name).await?; + // Sync man pages + #[cfg(unix)] + { + state_changes |= project.sync_man_pages(env_name).await?; + } + project.manifest.save().await?; Ok(state_changes) } diff --git a/src/cli/global/sync.rs b/src/cli/global/sync.rs index 8e235cdeb3..b0bda82185 100644 --- a/src/cli/global/sync.rs +++ b/src/cli/global/sync.rs @@ -27,6 +27,10 @@ pub async fn execute(args: Args) -> miette::Result<()> { // Prune broken completions let completions_dir = crate::global::completions::CompletionsDir::from_env().await?; completions_dir.prune_old_completions()?; + + // Prune broken man pages + let man_dir = crate::global::man_pages::ManDir::from_env().await?; + man_dir.prune_old_man_pages()?; } if state_change.has_changed() { diff --git a/src/cli/global/update.rs b/src/cli/global/update.rs index dd0df7a88a..2299cfbf92 100644 --- a/src/cli/global/update.rs +++ b/src/cli/global/update.rs @@ -76,6 +76,12 @@ pub async fn execute(args: Args) -> miette::Result<()> { // Sync completions state_changes |= project.sync_completions(env_name).await?; + // Sync man pages + #[cfg(unix)] + { + state_changes |= project.sync_man_pages(env_name).await?; + } + Ok(state_changes) } @@ -90,6 +96,9 @@ pub async fn execute(args: Args) -> miette::Result<()> { { let completions_dir = global::completions::CompletionsDir::from_env().await?; completions_dir.prune_old_completions()?; + + let man_dir = global::man_pages::ManDir::from_env().await?; + man_dir.prune_old_man_pages()?; } project_original.environments().keys().cloned().collect() } diff --git a/src/global/common.rs b/src/global/common.rs index d9f1b61e5c..b10d1f2b16 100644 --- a/src/global/common.rs +++ b/src/global/common.rs @@ -358,6 +358,8 @@ pub(crate) enum StateChange { AddedCompletion(String), #[allow(dead_code)] // This variant is not used on Windows RemovedCompletion(String), + AddedManPage(String), + RemovedManPage(String), } #[must_use] @@ -713,6 +715,66 @@ impl StateChanges { } } } + StateChange::AddedManPage(name) => { + let mut installed_items = StateChanges::accumulate_changes( + &mut iter, + |next| match next { + Some(StateChange::AddedManPage(name)) => Some(name.clone()), + _ => None, + }, + Some(name.clone()), + ); + + installed_items.sort(); + + if installed_items.len() == 1 { + eprintln!( + "{}Exposed man page {} of environment {}.", + console::style(console::Emoji("✔ ", "")).green(), + installed_items[0], + env_name.fancy_display() + ); + } else { + eprintln!( + "{}Exposed man pages of environment {}:", + console::style(console::Emoji("✔ ", "")).green(), + env_name.fancy_display() + ); + for installed_item in installed_items { + eprintln!(" - {}", installed_item); + } + } + } + StateChange::RemovedManPage(name) => { + let mut uninstalled_items = StateChanges::accumulate_changes( + &mut iter, + |next| match next { + Some(StateChange::RemovedManPage(name)) => Some(name.clone()), + _ => None, + }, + Some(name.clone()), + ); + + uninstalled_items.sort(); + + if uninstalled_items.len() == 1 { + eprintln!( + "{}Removed man page {} of environment {}.", + console::style(console::Emoji("✔ ", "")).green(), + uninstalled_items[0], + env_name.fancy_display() + ); + } else { + eprintln!( + "{}Removed man pages of environment {}:", + console::style(console::Emoji("✔ ", "")).green(), + env_name.fancy_display() + ); + for uninstalled_item in uninstalled_items { + eprintln!(" - {}", uninstalled_item); + } + } + } } } } diff --git a/src/global/man_pages.rs b/src/global/man_pages.rs new file mode 100644 index 0000000000..7ab5824fc9 --- /dev/null +++ b/src/global/man_pages.rs @@ -0,0 +1,300 @@ +/// This module contains code facilitating man page support for `pixi global` +use std::path::{Path, PathBuf}; + +use indexmap::IndexSet; +use itertools::Itertools; +use miette::IntoDiagnostic; +use pixi_config::pixi_home; + +use super::Mapping; +use super::StateChange; +use fs_err::tokio as tokio_fs; + +/// Global man pages directory, default to `$HOME/.pixi/share/man` +#[derive(Debug, Clone)] +pub struct ManDir(PathBuf); + +impl ManDir { + /// Create the global man pages directory from environment variables + pub async fn from_env() -> miette::Result { + let man_dir = pixi_home() + .map(|path| path.join("share").join("man")) + .ok_or(miette::miette!( + "Couldn't determine global man pages directory" + ))?; + tokio_fs::create_dir_all(&man_dir).await.into_diagnostic()?; + + // Create standard man page sections + for section in ["man1", "man3", "man5", "man8"] { + tokio_fs::create_dir_all(man_dir.join(section)) + .await + .into_diagnostic()?; + } + + Ok(Self(man_dir)) + } + + /// Returns the path to the man pages directory + pub fn path(&self) -> &Path { + &self.0 + } + + /// Prune old man pages + pub fn prune_old_man_pages(&self) -> miette::Result<()> { + for section in ["man1", "man3", "man5", "man8"] { + let section_dir = self.section_path(section); + if !section_dir.is_dir() { + continue; + } + + for entry in fs_err::read_dir(§ion_dir).into_diagnostic()? { + let path = entry.into_diagnostic()?.path(); + + if (path.is_symlink() && fs_err::read_link(&path).is_err()) + || (!path.is_symlink() && path.is_file()) + { + // Remove broken symlink or non-symlink files + fs_err::remove_file(&path).into_diagnostic()?; + } + } + } + + Ok(()) + } + + pub fn section_path(&self, section: &str) -> PathBuf { + self.path().join(section) + } +} + +#[derive(Debug, Clone)] +pub struct ManPage { + name: String, + source: PathBuf, + destination: PathBuf, + #[allow(dead_code)] // Used in tests but may be useful for future features + section: String, +} + +impl ManPage { + pub fn new(name: String, source: PathBuf, destination: PathBuf, section: String) -> Self { + Self { + name, + source, + destination, + section, + } + } + + /// Install the man page + pub async fn install(&self) -> miette::Result> { + tracing::debug!("Requested to install man page {}.", self.source.display()); + + // Ensure the parent directory of the destination exists + if let Some(parent) = self.destination.parent() { + tokio_fs::create_dir_all(parent).await.into_diagnostic()?; + } + + // Attempt to create the symlink + tokio_fs::symlink(&self.source, &self.destination) + .await + .into_diagnostic()?; + + Ok(Some(StateChange::AddedManPage(self.name.clone()))) + } + + /// Remove the man page + pub async fn remove(&self) -> miette::Result { + tokio_fs::remove_file(&self.destination) + .await + .into_diagnostic()?; + + Ok(StateChange::RemovedManPage(self.name.clone())) + } +} + +/// Generates a list of man pages for a given executable name. +/// +/// This function checks for the existence of man pages for the specified command +/// in the prefix_root directory. It looks in the standard man page sections (man1, man8, man3, man5) +/// and returns the first match found, prioritizing user commands (man1) and system commands (man8). +pub fn contained_man_pages( + prefix_root: &Path, + name: &str, + man_dir: &ManDir, +) -> miette::Result> { + let mut man_pages = Vec::new(); + + // Priority order: man1 (user commands), man8 (system admin), man3 (library), man5 (config) + let sections = [("man1", "1"), ("man8", "8"), ("man3", "3"), ("man5", "5")]; + + for (section_dir, section_num) in sections { + let man_page_name = format!("{name}.{section_num}"); + let man_path = prefix_root + .join("share") + .join("man") + .join(section_dir) + .join(&man_page_name); + + if man_path.exists() { + let destination = man_dir.section_path(section_dir).join(&man_page_name); + + man_pages.push(ManPage::new( + name.to_string(), + man_path, + destination, + section_num.to_string(), + )); + + // Only take the first man page found for each command + break; + } + } + + Ok(man_pages) +} + +/// Synchronizes the man pages for the given executable names. +/// +/// This function determines which man pages need to be removed or added +/// based on the provided `exposed_mappings` and `executable_names`. It compares the +/// current state of the man pages in the `man_dir` with the expected +/// state derived from the `exposed_mappings`. +pub(crate) async fn man_pages_sync_status( + exposed_mappings: IndexSet, + executable_names: Vec, + prefix_root: &Path, + man_dir: &ManDir, +) -> miette::Result<(Vec, Vec)> { + let mut man_pages_to_add = Vec::new(); + let mut man_pages_to_remove = Vec::new(); + + let exposed_names = exposed_mappings + .into_iter() + .filter(|mapping| mapping.exposed_name().to_string() == mapping.executable_name()) + .map(|name| name.executable_name().to_string()) + .collect_vec(); + + for name in executable_names.into_iter().unique() { + let man_pages = contained_man_pages(prefix_root, &name, man_dir)?; + + if man_pages.is_empty() { + continue; + } + + if exposed_names.contains(&name) { + for man_page in man_pages { + if !man_page.destination.is_symlink() { + man_pages_to_add.push(man_page); + } + } + } else { + for man_page in man_pages { + if man_page.destination.is_symlink() { + if let Ok(target) = tokio_fs::read_link(&man_page.destination).await { + if target == man_page.source { + man_pages_to_remove.push(man_page); + } + } + } + } + } + } + + Ok((man_pages_to_remove, man_pages_to_add)) +} + +#[cfg(test)] +mod tests { + use super::*; + use fs_err as fs; + use tempfile::tempdir; + + #[test] + fn test_man_dir_path_creation() { + let temp_dir = tempdir().unwrap(); + let man_dir_path = temp_dir.path().join("share").join("man"); + + // Test ManDir creation directly without async + let man_dir = ManDir(man_dir_path.clone()); + assert_eq!(man_dir.path(), &man_dir_path); + + // Test section path creation + let man1_path = man_dir.section_path("man1"); + assert_eq!(man1_path, man_dir_path.join("man1")); + } + + #[test] + fn test_contained_man_pages_finds_man1() { + let temp_dir = tempdir().unwrap(); + let prefix_root = temp_dir.path(); + + // Create man page structure + let man1_dir = prefix_root.join("share").join("man").join("man1"); + fs::create_dir_all(&man1_dir).unwrap(); + + // Create a man page + let man_page = man1_dir.join("test.1"); + fs::write(&man_page, "test man page content").unwrap(); + + // Create mock ManDir + let man_dir_path = temp_dir.path().join("global_man"); + fs::create_dir_all(&man_dir_path).unwrap(); + let man_dir = ManDir(man_dir_path); + + // Test the function + let result = contained_man_pages(prefix_root, "test", &man_dir).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "test"); + assert_eq!(result[0].section, "1"); + assert!(result[0].source.ends_with("test.1")); + } + + #[test] + fn test_contained_man_pages_priority_order() { + let temp_dir = tempdir().unwrap(); + let prefix_root = temp_dir.path(); + + // Create man page structure with multiple sections + for section in ["man1", "man3", "man5"] { + let section_dir = prefix_root.join("share").join("man").join(section); + fs::create_dir_all(§ion_dir).unwrap(); + + let section_num = section.strip_prefix("man").unwrap(); + let man_page = section_dir.join(format!("test.{}", section_num)); + fs::write(&man_page, format!("test man page section {}", section_num)).unwrap(); + } + + // Create mock ManDir + let man_dir_path = temp_dir.path().join("global_man"); + fs::create_dir_all(&man_dir_path).unwrap(); + let man_dir = ManDir(man_dir_path); + + // Test the function - should return only man1 (highest priority) + let result = contained_man_pages(prefix_root, "test", &man_dir).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].section, "1"); + } + + #[test] + fn test_contained_man_pages_no_man_page() { + let temp_dir = tempdir().unwrap(); + let prefix_root = temp_dir.path(); + + // Create man directory but no man pages + let man1_dir = prefix_root.join("share").join("man").join("man1"); + fs::create_dir_all(&man1_dir).unwrap(); + + // Create mock ManDir + let man_dir_path = temp_dir.path().join("global_man"); + fs::create_dir_all(&man_dir_path).unwrap(); + let man_dir = ManDir(man_dir_path); + + // Test the function + let result = contained_man_pages(prefix_root, "nonexistent", &man_dir).unwrap(); + + assert_eq!(result.len(), 0); + } +} diff --git a/src/global/mod.rs b/src/global/mod.rs index 3eb17e2361..b0aac15a40 100644 --- a/src/global/mod.rs +++ b/src/global/mod.rs @@ -3,6 +3,8 @@ pub(crate) mod common; pub(crate) mod completions; pub(crate) mod install; pub(crate) mod list; +#[cfg(unix)] // Man pages are only supported on unix-like systems +pub(crate) mod man_pages; pub(crate) mod project; pub(crate) mod trampoline; diff --git a/src/global/project/mod.rs b/src/global/project/mod.rs index cfb6228b4f..f4ee2febd0 100644 --- a/src/global/project/mod.rs +++ b/src/global/project/mod.rs @@ -651,6 +651,10 @@ impl Project { // Prune old completions let completions_dir = super::completions::CompletionsDir::from_env().await?; completions_dir.prune_old_completions()?; + + // Prune old man pages + let man_dir = super::man_pages::ManDir::from_env().await?; + man_dir.prune_old_man_pages()?; } state_changes.insert_change(env_name, StateChange::RemovedEnvironment); @@ -1025,6 +1029,12 @@ impl Project { // Sync completions state_changes |= self.sync_completions(env_name).await?; + // Sync man pages + #[cfg(unix)] + { + state_changes |= self.sync_man_pages(env_name).await?; + } + Ok(state_changes) } @@ -1260,6 +1270,57 @@ impl Project { Ok(state_changes) } + #[cfg(unix)] // Man pages are only supported on unix like systems + pub async fn sync_man_pages(&self, env_name: &EnvironmentName) -> miette::Result { + let mut state_changes = StateChanges::default(); + + let environment = self.environment(env_name).ok_or(miette::miette!( + "Environment {} not found in manifest.", + env_name.fancy_display() + ))?; + let prefix = self.environment_prefix(env_name).await?; + + let execs_all = self + .executables_of_all_dependencies(env_name) + .await? + .into_iter() + .map(|exec| exec.name) + .collect(); + + let man_dir = crate::global::man_pages::ManDir::from_env().await?; + let (man_pages_to_remove, man_pages_to_add) = super::man_pages::man_pages_sync_status( + environment.exposed.clone(), + execs_all, + prefix.root(), + &man_dir, + ) + .await?; + + for man_page_to_remove in man_pages_to_remove { + let state_change = man_page_to_remove.remove().await?; + state_changes.insert_change(env_name, state_change); + } + + for man_page_to_add in man_pages_to_add { + let Some(state_change) = man_page_to_add.install().await? else { + continue; + }; + + state_changes.insert_change(env_name, state_change); + } + + Ok(state_changes) + } + + #[cfg(not(unix))] + pub async fn sync_man_pages( + &self, + _env_name: &EnvironmentName, + ) -> miette::Result { + let state_changes = StateChanges::default(); + Ok(state_changes) + } + /// Returns a semaphore than can be used to limit the number of concurrent /// according to the user configuration. fn concurrent_downloads_semaphore(&self) -> Arc { diff --git a/tests/integration_python/pixi_global/test_man_pages.py b/tests/integration_python/pixi_global/test_man_pages.py new file mode 100644 index 0000000000..6a8a0c12ce --- /dev/null +++ b/tests/integration_python/pixi_global/test_man_pages.py @@ -0,0 +1,208 @@ +from pathlib import Path +import platform + +from ..common import exec_extension, verify_cli_command + + +def man_page_path(pixi_home: Path, executable: str, section: str = "1") -> Path: + return pixi_home.joinpath("share", "man", f"man{section}", f"{executable}.{section}") + + +def test_sync_exposes_man_pages(pixi: Path, tmp_pixi_workspace: Path, dummy_channel_1: str) -> None: + env = {"PIXI_HOME": str(tmp_pixi_workspace)} + manifests = tmp_pixi_workspace.joinpath("manifests") + manifests.mkdir() + manifest = manifests.joinpath("pixi-global.toml") + toml = f""" + [envs.test] + channels = ["{dummy_channel_1}"] + dependencies = {{ ripgrep = "*" }} + exposed = {{ rg = "rg" }} + """ + manifest.write_text(toml) + rg = tmp_pixi_workspace / "bin" / exec_extension("rg") + + # Man page + man_page = man_page_path(tmp_pixi_workspace, "rg") + + # Test basic commands + verify_cli_command([pixi, "global", "sync"], env=env) + assert rg.is_file() + + if platform.system() == "Windows": + # Man pages are ignored on Windows + assert not man_page.is_file() + else: + assert man_page.is_file() + assert man_page.is_symlink() + + # If the exposed executable is removed, the same should happen for the man page + verify_cli_command([pixi, "global", "expose", "remove", "rg"], env=env) + assert not man_page.is_file() + + +def test_only_self_expose_have_man_pages( + pixi: Path, tmp_pixi_workspace: Path, dummy_channel_1: str +) -> None: + env = {"PIXI_HOME": str(tmp_pixi_workspace)} + + # Install `ripgrep`, but expose `rg` under `ripgrep` + # Therefore no man pages should be installed + verify_cli_command( + [ + pixi, + "global", + "install", + "--channel", + dummy_channel_1, + "--expose", + "ripgrep=rg", + "ripgrep", + ], + env=env, + ) + + # Man page + man_page = man_page_path(tmp_pixi_workspace, "rg") + + assert not man_page.is_file() + + # When we add `rg=rg`, the man page should be installed + verify_cli_command( + [pixi, "global", "expose", "add", "--environment", "ripgrep", "rg=rg"], env=env + ) + + if platform.system() == "Windows": + # Man pages are ignored on Windows + assert not man_page.is_file() + else: + assert man_page.is_file() + assert man_page.is_symlink() + + # By uninstalling the environment, the man page should be removed as well + verify_cli_command([pixi, "global", "uninstall", "ripgrep"], env=env) + + assert not man_page.is_file() + + +def test_installing_same_package_again_without_expose_shouldnt_remove_man_page( + pixi: Path, tmp_pixi_workspace: Path, dummy_channel_1: str +) -> None: + env = {"PIXI_HOME": str(tmp_pixi_workspace)} + + # Man page + man_page = man_page_path(tmp_pixi_workspace, "rg") + + # Install `ripgrep`, and expose `rg` as `rg` + # This should install the man page + verify_cli_command( + [ + pixi, + "global", + "install", + "--channel", + dummy_channel_1, + "--expose", + "rg=rg", + "--environment", + "test-1", + "ripgrep", + ], + env=env, + ) + + if platform.system() == "Windows": + # Man pages are ignored on Windows + assert not man_page.is_file() + else: + assert man_page.is_file() + assert man_page.is_symlink() + + # Install `ripgrep`, but expose `rg` under `ripgrep` + # Therefore no man pages should be installed + # But existing ones should also not be removed + verify_cli_command( + [ + pixi, + "global", + "install", + "--channel", + dummy_channel_1, + "--expose", + "ripgrep=rg", + "--environment", + "test-2", + "ripgrep", + ], + env=env, + ) + + if platform.system() == "Windows": + # Man pages are ignored on Windows + assert not man_page.is_file() + else: + assert man_page.is_file() + assert man_page.is_symlink() + + +def test_man_page_priority_order( + pixi: Path, tmp_pixi_workspace: Path, dummy_channel_1: str +) -> None: + """Test that man page priority order (man1 > man8 > man3 > man5) is respected""" + env = {"PIXI_HOME": str(tmp_pixi_workspace)} + + # Install a command that could potentially have multiple man page sections + verify_cli_command( + [ + pixi, + "global", + "install", + "--channel", + dummy_channel_1, + "bash", + ], + env=env, + ) + + if platform.system() != "Windows": + # bash should get bash.1 (user commands) not bash.3 (library functions) + man_page_1 = man_page_path(tmp_pixi_workspace, "bash", "1") + man_page_3 = man_page_path(tmp_pixi_workspace, "bash", "3") + + # Only one man page should be symlinked (the highest priority one) + # Note: This test assumes bash has a man1 page, which it typically does + if man_page_1.is_file(): + assert man_page_1.is_symlink() + # Should not create lower priority man pages if higher priority exists + assert not man_page_3.is_file() + + +def test_man_page_without_man_page_doesnt_error( + pixi: Path, tmp_pixi_workspace: Path, dummy_channel_1: str +) -> None: + """Test that commands without man pages don't cause errors""" + env = {"PIXI_HOME": str(tmp_pixi_workspace)} + + # Install a simple package that likely doesn't have man pages + # Using a minimal package or one known not to have man pages + verify_cli_command( + [ + pixi, + "global", + "install", + "--channel", + dummy_channel_1, + "python", # Python might not have man pages in all packages + ], + env=env, + ) + + # Verify the command was installed successfully even without man pages + python_exec = tmp_pixi_workspace / "bin" / exec_extension("python") + assert python_exec.is_file() + + # Man page directory should exist but no python man page is required + man_dir = tmp_pixi_workspace / "share" / "man" / "man1" + if platform.system() != "Windows": + assert man_dir.is_dir() + # No assertion about python.1 existing - that's package dependent