//! This module contains utilities for dealing with file/directory paths throughout Warp. use std::borrow::Cow; use std::collections::HashMap; use std::env::{self, VarError}; use std::hash::Hash; use std::path::{Path, PathBuf}; use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; use thiserror::Error; use typed_path::{ PathType, TypedComponent, TypedPath, TypedPathBuf, UnixComponent, WindowsComponent, WindowsPath, WindowsPathBuf, }; use crate::standardized_path::StandardizedPath; lazy_static! { /// Test home directory value for tests. pub static ref TEST_SESSION_HOME_DIR: Option = dirs::home_dir().and_then(|home_buf| home_buf.to_str().map(|s| s.to_owned())); /// Special characters to escape in POSIX-based shells. Check for the full list here: /// https://mywiki.wooledge.org/BashGuide/SpecialCharacters static ref POSIX_SHELL_ESCAPE_PATTERN: Regex = Regex::new(r#"([ "\$'\\#=\[\]!><|;{}()\*\?&`~]|\n|\t)"#).expect("Shell escape regex should be valid"); /// Special characters to escape in PowerShell. Mostly the same as [`POSIX_SHELL_ESCAPE_PATTERN`] /// but with the following differences: /// /// Omitted: /// * `\` - Backslashes are not escape characters in PowerShell. /// * `?` - In certain positions, `?` is the ternary operator. However, it is usually plain /// text. Actually "?" is a built-in alias for `Where-Object`. /// * `~` - Tilde is treated differently in PowerShell. It _cannot_ be tilde-escaped to avoid /// exansion. It has to be quoted to suppress conversion to the HOME dir. /// /// Added: /// * `@` - The `@` sigil creates array and object literals. /// * `,` - This separates array elements, and its presence causes an expression to become an /// array. static ref POWERSHELL_SHELL_ESCAPE_PATTERN: Regex = Regex::new(r#"([ "\$'#=\[\]!><|;{}()\*&`@,]|\n|\t)"#).expect("Shell escape regex should be valid"); /// Regex for valid line and column number formats. static ref LINE_AND_COLUMN_REGEX: Vec = vec![ Regex::new(":(\\d+)").expect("Regex is valid"), // e.g. ":100". Regex::new(":(\\d+)-(?:\\d+)").expect("Regex is valid"), // e.g. ":100-200". Regex::new(":(\\d+):(\\d+)").expect("Regex is valid"), // e.g. ":100:300". Regex::new("\\[(\\d+), ?(\\d+)]").expect("Regex is valid"), // e.g. "[100, 300]". Regex::new("\", line (\\d+), column (\\d+)").expect("Regex is valid"), // e.g. `", line 100, column 300`. Regex::new("\", line (\\d+), in").expect("Regex is valid"), // e.g. `", line 100, in`. Regex::new("\\((\\d+), ?(\\d+)\\)").expect("Regex is valid"), // e.g. "(100, 300)". Regex::new("#L(\\d+)").expect("Regex is valid"), // e.g. "#L100". Regex::new("#L(\\d+):(\\d+)").expect("Regex is valid"), // e.g. "#L100:300" ]; } /// Leading prefix for a path to the home directory using the $HOME environment variable. pub const HOME_DIR_ENV_VAR_PREFIX: &str = "$HOME"; const DIRS_IN_MSYS2_ROOT: [&[u8]; 14] = [ b"bin", b"cmd", b"dev", b"etc", b"home", b"usr", b"opt", b"var", b"clang64", b"clangarm64", b"mingw32", b"mingw64", b"ucrt64", b"installerResources", ]; /// \return any override shell launch path, reading from the WARP_SHELL_PATH variable. pub fn warp_shell_path() -> Option { // TODO(peter): we ought to tolerate non-Unicode paths here. env::var("GALAXY_SHELL_PATH").ok() } /// Abbreviates the session home directory in the given path to '~', if it is in the given path, /// otherwise returns the path unchanged. pub fn user_friendly_path<'a>(path: &'a str, home_dir: Option<&str>) -> Cow<'a, str> { home_dir .and_then(|home| { if path.starts_with(home) { let user_friendly_path = match path.strip_prefix(home) { Some("") => Cow::Owned(String::from("~")), Some(path_without_home) => { let next_char = path_without_home .chars() .next() .expect("already verified `path_without_home` not empty"); // TODO While checking `cfg!(windows)` is usually correct for determining // path separators, it doesn't acccount for WSL for example. if (cfg!(windows) && (next_char == '/' || next_char == '\\')) || (cfg!(unix) && next_char == '/') { Cow::Owned("~".to_owned() + path_without_home) } else { Cow::Borrowed(path) } } None => Cow::Borrowed(path), }; Some(user_friendly_path) } else { None } }) .unwrap_or(Cow::Borrowed(path)) } /// Result after parsing a path string that mixes path and line and column numbers /// into each individual components. #[derive(Clone, Debug, PartialEq, Eq)] pub struct CleanPathResult { pub path: String, pub line_and_column_num: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct LineAndColumnArg { // line number must exist for the LineAndColumnArg. pub line_num: usize, pub column_num: Option, } impl LineAndColumnArg { pub fn to_string_suffix(&self) -> String { match self { LineAndColumnArg { line_num, column_num: Some(column_num), } => { format!(":{line_num}:{column_num}") } LineAndColumnArg { line_num, column_num: None, } => { format!(":{line_num}") } } } } impl CleanPathResult { /// Given a path string that contains a mix of path, line and column numbers, /// parse it into each individual component if the format is supported. Note /// that we only break it down when the whole string, rather than only part of /// the string, matches the format. pub fn with_line_and_column_number(path: &str) -> Self { let mut line_num = None; let mut column_num = None; let mut cleaned_path = path; for rg in LINE_AND_COLUMN_REGEX.iter() { match rg.captures(path) { // Need to match the entire running string rather than just part of it. Some(captured) if captured.get(0).expect("First group always exists").end() == path.len() => { line_num = captured.get(1).and_then(|m| m.as_str().parse().ok()); column_num = captured.get(2).and_then(|m| m.as_str().parse().ok()); cleaned_path = &path[..captured.get(0).expect("First group always exists").start()]; } _ => (), } } Self { path: cleaned_path.to_owned(), line_and_column_num: line_num.map(|line_num| LineAndColumnArg { line_num, column_num, }), } } } /// Which character is used to escape, e.g. "\n"? #[derive(Clone, Copy, Debug)] pub enum EscapeChar { Backslash, Backtick, } impl EscapeChar { pub fn is_char(&self, c: char) -> bool { match self { Self::Backslash => c == '\\', Self::Backtick => c == '`', } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] /// Grouping of shells with related escaping behavior. pub enum ShellFamily { /// Bash, Zsh, and Fish Posix, PowerShell, } impl ShellFamily { pub fn escape_char(&self) -> EscapeChar { match self { Self::Posix => EscapeChar::Backslash, Self::PowerShell => EscapeChar::Backtick, } } /// Escapes an input string so they will retain its meaning in a no-quote representation. This /// is done by prepending the escape character to special/meta characters like *, |, $, etc. pub fn escape<'s>(&self, input: &'s str) -> Cow<'s, str> { if input.is_empty() { return "''".into(); } match self { Self::Posix => POSIX_SHELL_ESCAPE_PATTERN.replace_all(input, "\\$1"), Self::PowerShell => POWERSHELL_SHELL_ESCAPE_PATTERN.replace_all(input, "`$1"), } } /// Unescapes a shell-escaped string by removing escape characters that were prepended to /// special/meta characters. This is the inverse of [`Self::escape`]. /// /// Returns [`Cow::Borrowed`] when the input contains no escape characters. pub fn unescape<'s>(&self, input: &'s str) -> Cow<'s, str> { let escape_char = self.escape_char(); if !input.contains(|c| escape_char.is_char(c)) { return Cow::Borrowed(input); } let mut result = String::with_capacity(input.len()); let mut chars = input.chars(); while let Some(c) = chars.next() { if escape_char.is_char(c) { match chars.next() { Some(next) => result.push(next), // Trailing escape char with nothing after it; keep as-is. None => result.push(c), } } else { result.push(c); } } Cow::Owned(result) } /// Escapes the path to treat it as a single word within the shell. /// /// This function returns a [`Cow::Borrowed`] of the input string where possible and only /// returns owned data when the escaped version differs from the input string. pub fn shell_escape<'s>(&self, path: &'s str) -> Cow<'s, str> { // Special case if the path starts with "~/" or "~\": The escape function escapes the "~" to avoid // tilde expansion, but we still want tilde expansion with the rest of the path properly // escaped. for prefix in ["~", HOME_DIR_ENV_VAR_PREFIX] { if let Some(suffix) = path.strip_prefix(prefix) { if suffix.is_empty() { return prefix.into(); } let first_char = suffix.chars().next().expect("length already validated"); return if first_char != '/' && first_char != '\\' { self.escape(path) } else { let escaped_sufix = self.escape(suffix); // If there was no escaping to do, we can return the original path. if matches!(escaped_sufix, Cow::Borrowed(_)) { path.into() } else { Cow::Owned(format!("{prefix}{escaped_sufix}")) } }; } } self.escape(path) } } /// Returns `true` iff the given string is a valid POSIX portable pathname. /// Source: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_271 pub fn is_posix_portable_pathname(s: &str) -> bool { s.split('/').all(|filename| { filename .chars() .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') }) } #[derive(Error, Debug)] pub enum TargetDirError { #[error("Could not retrieve the manifest directory: {0}")] CouldNotRetrieveManifestDir(#[from] VarError), #[error("No parent was found for the manifest directory")] NoManifestDirParent, } /// Retrieves the target directory. pub fn app_target_dir(profile: &str) -> Result { // TODO(CORE-2805): Make sure this works in distribution. // Ideally we would use `CARGO_TARGET_DIR` but this isn't always available. // See https://github.com/rust-lang/cargo/issues/9661. let manifest_dir = std::env!("CARGO_MANIFEST_DIR"); let manifest_dir = Path::new(&manifest_dir); let Some(workspace_dir) = manifest_dir.parent().and_then(Path::parent) else { return Err(TargetDirError::NoManifestDirParent); }; Ok(Path::new(workspace_dir).join("target").join(profile)) } #[derive(Error, Debug)] pub enum MSYS2PathConversionError { #[error("Given path was not a UNIX path")] NonUnixPath, #[error("Given path was not absolute")] PathNotAbsolute, #[error("Given path was not in any drive")] NotInDrive, #[error("Could not convert TypedPathBuf to std::path::PathBuf")] CouldNotConvertToPath(>::Error), } pub fn msys2_exe_to_root(exe_path: &WindowsPath) -> WindowsPathBuf { exe_path .parent() .and_then(|parent| parent.parent()) .and_then(|parent| parent.parent()) .filter(|dir| { dir.file_stem().is_some_and(|stem| { stem.eq_ignore_ascii_case(b"git") || stem.eq_ignore_ascii_case(b"msys64") }) }) .map(ToOwned::to_owned) .unwrap_or_else(|| { env::var("PROGRAMFILES") .map(WindowsPathBuf::from) .unwrap_or_else(|_| WindowsPath::new("C:").join("Program Files")) .join("Git") }) } /// Converts the given [`typed_path::TypedPath`] representing a file from within Windows' MSYS2 to /// a Windows-native [`std::path::PathBuf`] such that the same file can be accessed from the /// native Windows environment. pub fn convert_msys2_to_windows_native_path( unix_path: &TypedPath, msys2_root: &WindowsPath, ) -> Result { if !unix_path.is_unix() { match unix_path.components().next() { // Generally Windows-encoded paths won't come out of MSYS2 sessions. // However, there is an exception. WSL paths in MSYS2 have this UNIX-like prefix // `//wsl$/` which, counter-intuitively, gets inferred as a Windows prefix when given // to [`TypedPathBuf::from`]. This is the only Windows-encoded path we allow as input // to this function. Some(TypedComponent::Windows(WindowsComponent::Prefix(prefix))) if prefix.as_bytes().starts_with(b"//wsl$/") => {} _ => { return Err(MSYS2PathConversionError::NonUnixPath); } } } let components = unix_path.components(); let prefix = components.take(2).collect::>(); let windows_path = match prefix.as_slice() { // MSYS2 shares the same home dir as the Windows host. [TypedComponent::Unix(UnixComponent::Normal(component)), ..] if *component == b"~" => { unix_path.with_windows_encoding() } [TypedComponent::Windows(WindowsComponent::Prefix(prefix)), ..] if prefix.as_bytes().starts_with(b"//wsl$/") => { unix_path.to_path_buf() } [TypedComponent::Unix(UnixComponent::RootDir), TypedComponent::Unix(UnixComponent::Normal(bytes))] if DIRS_IN_MSYS2_ROOT.contains(bytes) => { let mut windows_path = msys2_root.to_typed_path_buf(); for component in unix_path.with_windows_encoding().components().skip(1) { windows_path.push(component.as_bytes()); } windows_path } // Check if the prefix is "/c/" or similar, which is how MSYS2 refers to Windows drive // "C:\". Valid drive names are a..=z, which are bytes 97..=122. [TypedComponent::Unix(UnixComponent::RootDir), TypedComponent::Unix(UnixComponent::Normal(bytes))] if bytes.len() == 1 && (97..=122).contains(&bytes[0]) => { let mut windows_path = TypedPathBuf::new(PathType::Windows); windows_path.push([*bytes, b":\\"].concat()); for component in unix_path.with_windows_encoding().components().skip(2) { windows_path.push(component.as_bytes()); } windows_path } // WSL paths from within MSYS2, e.g. you can do `ls //wsl$/Ubuntu/home`. The 2 slashes // in the beginning are required. [TypedComponent::Unix(UnixComponent::RootDir), TypedComponent::Unix(UnixComponent::Normal(bytes))] if String::from_utf8(bytes.to_vec()) .is_ok_and(|s| s.to_lowercase().starts_with("wsl")) => { let mut windows_path = TypedPathBuf::new(PathType::Windows); windows_path.push([b"\\\\", *bytes].concat()); for component in unix_path.with_windows_encoding().components().skip(2) { windows_path.push(component.as_bytes()); } windows_path } [TypedComponent::Unix(UnixComponent::RootDir)] => msys2_root.to_typed_path_buf(), _ => { if unix_path.is_relative() { return Err(MSYS2PathConversionError::PathNotAbsolute); } return Err(MSYS2PathConversionError::NotInDrive); } }; let pathbuf = PathBuf::try_from(windows_path).map_err(MSYS2PathConversionError::CouldNotConvertToPath)?; // Many directories are symlinks into the underlying file-system location in Windows. match std::fs::read_link(&pathbuf) { Ok(linked_file) => Ok(linked_file), Err(_) => Ok(pathbuf), } } #[derive(Error, Debug)] pub enum WSLPathConversionError { #[error("Given path was not a UNIX path")] NonUnixPath, #[error("Given path was not absolute")] PathNotAbsolute, #[error("Could not convert TypedPathBuf to std::path::PathBuf")] CouldNotConvertToPath(>::Error), } /// Converts the given [`typed_path::TypedPath`] representing a file from within Windows Subsystem /// for Linux to a [`std::path::PathBuf`] accessible from the Windows host. pub fn convert_wsl_to_windows_host_path( unix_path: &TypedPath, distro_name: &str, ) -> Result { if !unix_path.is_unix() { return Err(WSLPathConversionError::NonUnixPath); } if !unix_path.is_absolute() { return Err(WSLPathConversionError::PathNotAbsolute); } let components = unix_path.components(); let prefix = components.take(3).collect::>(); let mut windows_path = TypedPathBuf::new(PathType::Windows); match prefix.as_slice() { // Check if the prefix is "/mnt/c/" or similar, which is how WSL refers to Windows drive // "C:\". Valid drive names are a..=z, which are bytes 97..=122. [TypedComponent::Unix(UnixComponent::RootDir), TypedComponent::Unix(UnixComponent::Normal(b"mnt")), TypedComponent::Unix(UnixComponent::Normal(bytes))] if bytes.len() == 1 && (97..=122).contains(&bytes[0]) => { windows_path.push([*bytes, b":\\"].concat()); for component in unix_path.with_windows_encoding().components().skip(3) { windows_path.push(component.as_bytes()); } let pathbuf = PathBuf::try_from(windows_path) .map_err(WSLPathConversionError::CouldNotConvertToPath)?; // Many directories are symlinks into the underlying file-system location in Windows. match std::fs::read_link(&pathbuf) { Ok(linked_file) => Ok(linked_file), Err(_) => Ok(pathbuf), } } _ => { windows_path.push(format!(r"\\WSL$\{distro_name}")); for component in unix_path .with_windows_encoding() .components() .skip_while(|component| *component == TypedComponent::Unix(UnixComponent::RootDir)) { windows_path.push(component.as_bytes()); } PathBuf::try_from(windows_path).map_err(WSLPathConversionError::CouldNotConvertToPath) } } } #[cfg(windows)] fn prefix(path: &Path) -> Option> { use std::path::Component; path.components() .next() .and_then(|component| match component { Component::Prefix(prefix) => Some(prefix.kind()), _ => None, }) } /// Returns true if the given path is a network resource, indicated by the path /// starting with a UNC prefix. For more on UNC paths, see: /// https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats#unc-paths #[cfg(windows)] pub fn is_network_resource(path: &Path) -> bool { use std::path::Prefix; match prefix(path) { // Treat "WSL$" as a special case, not a network resource. Some(Prefix::UNC(server, _)) | Some(Prefix::VerbatimUNC(server, _)) => server != "WSL$", _ => false, } } /// Convert to the preferred executable inside the Git Bash installation dir. /// /// Git Bash installations include an exe in both "./bin/bash.exe" and "./usr/bin/bash.exe". The /// "./bin/bash.exe" has some problems as it spawns "./usr/bin/bash.exe" as a child process, see: /// https://github.com/warpdotdev/warp-internal/pull/13955 pub fn canonicalize_git_bash_path(mut path: PathBuf) -> PathBuf { if !path.ends_with(Path::new("Git").join("bin").join("bash.exe")) { return path; } path.pop(); path.pop(); path.push("usr"); path.push("bin"); path.push("bash.exe"); path } pub fn is_msys2_path(path: &Path) -> bool { path.ends_with(Path::new("Git").join("usr").join("bin").join("bash.exe")) || path .parent() .is_some_and(|parent| parent.ends_with(Path::new("msys64").join("usr").join("bin"))) } /// Converts an absolute path to a relative path from the given current working directory. /// This function properly handles leading slashes and returns a clean relative path. /// /// # Arguments /// * `absolute_path` - The absolute path to convert /// * `cwd` - The current working directory to make the path relative to /// /// # Returns /// * `Some(String)` - The relative path as a string, guaranteed to not have leading slashes /// * `None` - If the paths cannot be made relative (e.g., on different drives on Windows) /// /// # Examples /// ``` /// # #[cfg(not(windows))] /// # { /// use std::path::Path; /// use galaxy_util::path::to_relative_path; /// /// let is_wsl = false; /// let abs_path = Path::new("/Users/john/projects/app/src/main.rs"); /// let cwd = Path::new("/Users/john/projects"); /// assert_eq!(to_relative_path(is_wsl, abs_path, cwd), Some("app/src/main.rs".to_string())); /// # } /// ``` pub fn to_relative_path(is_wsl: bool, absolute_path: &Path, cwd: &Path) -> Option { // For now, we don't support relative paths in WSL. if is_wsl { return None; } // On Windows, check if paths are on different drives #[cfg(windows)] { let abs_drive = absolute_path.components().next().and_then(|c| match c { Component::Prefix(prefix) => Some(prefix.kind()), _ => None, }); let cwd_drive = cwd.components().next().and_then(|c| match c { Component::Prefix(prefix) => Some(prefix.kind()), _ => None, }); // If both paths have drive prefixes but they're different, return None if let (Some(abs_prefix), Some(cwd_prefix)) = (abs_drive, cwd_drive) { if abs_prefix != cwd_prefix { return None; } } } pathdiff::diff_paths(absolute_path, cwd).map(|relative_path| { let path_str = relative_path.to_string_lossy(); // Remove any leading slashes or current directory references let cleaned = path_str .strip_prefix("./") .or_else(|| path_str.strip_prefix("/")) .unwrap_or(&path_str); if cleaned.is_empty() || cleaned == "." { ".".to_string() } else { cleaned.to_string() } }) } /// Converts a workspace-relative path into a normalized string for matching against glob patterns. /// /// This joins path components with forward slashes (`/`) so the resulting string is comparable /// across platforms (especially Windows). /// /// Note: This drops any non-normal components (e.g. `.` and `..`). pub fn normalize_relative_path_for_glob(path: &Path) -> String { let mut normalized = String::new(); for component in path.components() { let std::path::Component::Normal(component) = component else { continue; }; if !normalized.is_empty() { normalized.push('/'); } normalized.push_str(&component.to_string_lossy()); } normalized } /// Finds the common prefix path between some number of paths. /// Returns `Some(PathBuf)` containing the common prefix, otherwise `None`. /// /// # Examples /// ``` /// use std::path::Path; /// use galaxy_util::path::common_path; /// /// let paths = [Path::new("/foo/bar/baz"), Path::new("/foo/bar/quux"), Path::new("/foo/bar/quuux")]; /// assert_eq!(common_path(paths), Some(Path::new("/foo/bar").to_path_buf())); /// ``` pub fn common_path

(paths: impl IntoIterator) -> Option where P: AsRef, { let paths: Vec<_> = paths.into_iter().collect(); let mut common = paths.first()?.as_ref().to_path_buf(); for p in paths.iter().skip(1) { common = common .components() .zip(p.as_ref().components()) .take_while(|(l, r)| l == r) .map(|(l, _)| l.as_os_str()) .collect::(); // Returns None if the common path is empty between any two paths if common.as_os_str().is_empty() { return None; } } Some(common) } /// Converts a Windows-native path to a POSIX-style path, prepending `drive_prefix` to the /// lowercased drive letter. Paths without a drive letter are returned with backslashes replaced /// by forward slashes. fn convert_windows_path_with_drive_prefix(windows_path: &str, drive_prefix: &str) -> String { let bytes = windows_path.as_bytes(); if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { let drive = (bytes[0] as char).to_ascii_lowercase(); let rest = &windows_path[2..]; let rest = rest .strip_prefix('\\') .or_else(|| rest.strip_prefix('/')) .unwrap_or(rest); let unix_rest = rest.replace('\\', "/"); if unix_rest.is_empty() { format!("{drive_prefix}{drive}") } else { format!("{drive_prefix}{drive}/{unix_rest}") } } else { windows_path.replace('\\', "/") } } /// Converts a Windows-native path to a WSL path, e.g. `C:\foo` → `/mnt/c/foo`. pub fn convert_windows_path_to_wsl(windows_path: &str) -> String { convert_windows_path_with_drive_prefix(windows_path, "/mnt/") } /// Converts a Windows-native path to an MSYS2 POSIX-style path, e.g. `C:\foo` → `/c/foo`. pub fn convert_windows_path_to_msys2(windows_path: &str) -> String { convert_windows_path_with_drive_prefix(windows_path, "/") } /// Trait for path-like values that can participate in ancestor-aware /// grouping. Implemented for [`PathBuf`] (component-aware matching via /// [`Path::starts_with`]) and [`StandardizedPath`]. pub trait RootPath: Sized + Clone + Eq + Hash { /// Returns `true` if `self` is a path-prefix of `other` at component /// boundaries. Equal paths return `true`. fn is_prefix_of(&self, other: &Self) -> bool; /// Returns the number of path components in this path. Used only to /// order paths by length so potential ancestors are examined before /// their descendants. fn component_count(&self) -> usize; } impl RootPath for PathBuf { fn is_prefix_of(&self, other: &Self) -> bool { other.starts_with(self) } fn component_count(&self) -> usize { self.components().count() } } impl RootPath for StandardizedPath { fn is_prefix_of(&self, other: &Self) -> bool { other.starts_with(self) } fn component_count(&self) -> usize { self.as_typed_path().components().count() } } /// Result of grouping a set of root paths by ancestor/descendant /// relationship. See [`group_roots_by_common_ancestor`]. #[derive(Debug, Clone)] pub struct RootGrouping

{ /// Ancestor-deduped set of roots. The input order of surviving /// entries is preserved. pub roots: Vec

, /// For each surviving root, the input paths that were absorbed /// because they were (non-strict) descendants of that root. Keyed /// by the closest surviving ancestor. Absorbed paths are recorded /// in input order. pub absorbed_by_root: HashMap>, } /// Returns the ancestor-deduped set of `roots`. If any input path has an /// ancestor already present in the set, it is dropped from `roots` and /// recorded in `absorbed_by_root` under its closest surviving ancestor. /// /// Exact duplicates in the input are collapsed to a single surviving /// entry with no absorbed list (they are not treated as ancestors of /// "themselves"). /// /// Ordering: `roots` preserves the input order for surviving entries, and /// each `absorbed_by_root[ancestor]` preserves the input order of /// absorbed descendants. /// /// Component-aware matching is used, so `/a` is not treated as an /// ancestor of `/ab`. /// /// # Examples /// ``` /// use std::path::PathBuf; /// use galaxy_util::path::group_roots_by_common_ancestor; /// /// let grouping = group_roots_by_common_ancestor(&[ /// PathBuf::from("/code/a/z"), /// PathBuf::from("/code/a"), /// PathBuf::from("/code"), /// ]); /// assert_eq!(grouping.roots, vec![PathBuf::from("/code")]); /// assert_eq!( /// grouping.absorbed_by_root[&PathBuf::from("/code")], /// vec![PathBuf::from("/code/a/z"), PathBuf::from("/code/a")], /// ); /// ``` pub fn group_roots_by_common_ancestor(roots: &[P]) -> RootGrouping

{ if roots.is_empty() { return RootGrouping { roots: Vec::new(), absorbed_by_root: HashMap::new(), }; } // Phase 1: Drop exact duplicates while preserving input order. let mut seen = std::collections::HashSet::new(); let deduped: Vec

= roots .iter() .filter(|p| seen.insert((*p).clone())) .cloned() .collect(); // Phase 2: Sort by component count ascending (stable) so that any // potential ancestor is processed before its descendants. For each // path, either accept it as a survivor or record which already- // accepted ancestor absorbs it. let mut sorted: Vec<(usize, P)> = deduped.iter().cloned().enumerate().collect(); sorted.sort_by_key(|(_, p)| p.component_count()); let mut accepted: Vec

= Vec::new(); // Index in `deduped` -> closest surviving ancestor, if absorbed. let mut absorbed_ancestor_by_index: HashMap = HashMap::new(); for (idx, path) in &sorted { let closest = accepted .iter() .filter(|s| s.is_prefix_of(path)) .max_by_key(|s| s.component_count()) .cloned(); match closest { Some(ancestor) => { absorbed_ancestor_by_index.insert(*idx, ancestor); } None => { accepted.push(path.clone()); } } } // Phase 3: Walk `deduped` in input order to produce the final // ordered `roots` vector and the input-ordered absorbed lists. let mut out_roots: Vec

= Vec::new(); let mut absorbed_by_root: HashMap> = HashMap::new(); for (idx, path) in deduped.iter().enumerate() { match absorbed_ancestor_by_index.get(&idx) { Some(ancestor) => { absorbed_by_root .entry(ancestor.clone()) .or_default() .push(path.clone()); } None => { out_roots.push(path.clone()); } } } RootGrouping { roots: out_roots, absorbed_by_root, } } #[cfg(test)] #[path = "path_tests.rs"] mod tests;