Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
+57
View File
@@ -0,0 +1,57 @@
use std::path::{Path, PathBuf};
pub const ASSETS_DIR: &str = "assets";
pub const BUNDLED_ASSETS_DIR: &str = "bundled";
pub const ASYNC_ASSETS_DIR: &str = "async";
pub const REMOTE_ASSETS_DIR: &str = "remote";
pub const WINDOWS_ASSETS_DIR: &str = "windows";
pub const CONPTY_DLL_FILE: &str = "conpty.dll";
pub const OPEN_CONSOLE_EXE_FILE: &str = "OpenConsole.exe";
pub const DXCOMPILER_DLL_FILE: &str = "dxcompiler.dll";
pub const DXIL_DLL_FILE: &str = "dxil.dll";
/// Returns the relative path where an asset should be stored based on its path name and the sha256
/// hash of the contents.
/// The result will be of the form `path/to/file/filename-HASH.extension`
pub fn hashed_asset_path(asset_path: &Path, sha256_hash: &[u8]) -> PathBuf {
// We use the sha256 hash here because that's also what's used by RustEmbed.
let hash_str = hex::encode(sha256_hash);
// There aren't many ways to manipulate PathBufs or OsStrings, so we build the new name
// manually.
let mut new_name = asset_path
.file_stem()
.expect("Path should not be empty")
.to_os_string();
new_name.push("-");
new_name.push(hash_str);
if let Some(extension) = asset_path.extension() {
new_name.push(".");
new_name.push(extension);
}
asset_path.with_file_name(new_name)
}
/// Returns a domain-relative URL of an async asset based on it's hashed asset path.
pub fn hashed_asset_url(hashed_asset_path: &Path) -> String {
// This needs to be kept in sync with:
// - The local asset server in the serve-wasm dir.
// - The staging load balancer paths: https://console.cloud.google.com/net-services/loadbalancing/edit/http/serverless-lb?hl=en&project=warp-server-staging
// - The prod load balancer paths: https://console.cloud.google.com/net-services/loadbalancing/edit/http/app-warp-dev-lb?hl=en&project=astral-field-294621
format!(
"/assets/client/static/{}",
hashed_asset_path.to_str().unwrap()
)
}
#[cfg(target_family = "wasm")]
/// Makes a domain-relative url absolute by prepending the current origin.
pub fn make_absolute_url(relative_url: &str) -> String {
// This should be infallible.
let origin = gloo::utils::window()
.location()
.origin()
.expect("Can't get window origin.");
format!("{origin}{relative_url}")
}
+25
View File
@@ -0,0 +1,25 @@
/// A app-unique version number for content.
/// This is used for tracking and comparing versions of content across the application.
/// The Rich Text Buffer and the LocalFileModel use this for comparing versions of content.
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Clone, PartialEq, Debug, Copy, Eq, PartialOrd, Ord, Hash)]
pub struct ContentVersion(usize);
impl ContentVersion {
/// Constructs a new app-unique content version.
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
let raw = NEXT_ID.fetch_add(1, Ordering::Relaxed);
ContentVersion(raw)
}
pub fn as_i32(&self) -> i32 {
self.0 as i32
}
}
#[cfg(test)]
#[path = "content_version_test.rs"]
mod tests;
@@ -0,0 +1,22 @@
use super::*;
#[test]
fn test_create_version() {
ContentVersion::new();
}
#[test]
fn test_versions_equal() {
let version1 = ContentVersion::new();
let version2 = version1;
assert_eq!(version1, version2);
}
#[test]
fn test_versions_not_equal() {
let version1 = ContentVersion::new();
let version2 = ContentVersion::new();
assert_ne!(version1, version2);
}
+40
View File
@@ -0,0 +1,40 @@
use std::{
io,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
};
#[derive(thiserror::Error, Debug)]
pub enum FileSaveError {
#[error("No file path associated with file when saving file {0:?}")]
NoFilePath(FileId),
#[error("IO error when saving file.")]
IOError {
#[source]
error: io::Error,
path: PathBuf,
},
#[error("Remote file operation failed: {0}")]
RemoteError(String),
}
#[derive(thiserror::Error, Debug)]
pub enum FileLoadError {
#[error("File does not exist")]
DoesNotExist,
#[error("IO error when loading file.")]
IOError(#[from] io::Error),
}
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct FileId(usize);
impl FileId {
/// Constructs a new globally-unique file ID.
#[allow(clippy::new_without_default)]
pub fn new() -> FileId {
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
let raw = NEXT_ID.fetch_add(1, Ordering::Relaxed);
FileId(raw)
}
}
+408
View File
@@ -0,0 +1,408 @@
//! File type detection utilities.
//!
//! This module provides utilities for determining whether a file is likely to be a text file
//! based on its filename and extension. It uses a hybrid approach combining MIME type detection
//! with explicit extension checking for edge cases.
use content_inspector::{inspect, ContentType};
use mime_guess::{self, mime};
use std::fs::File;
use std::io::Read;
use std::path::Path;
/// File extensions for Markdown files.
const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown"];
/// Names of files that are typically Markdown or plain text.
const MARKDOWN_FILE_NAMES: &[&str] = &["README", "CHANGELOG", "LICENSE"];
/// Checks if a buffer appears to contain binary content.
/// Returns true if the buffer appears to be binary, false if it appears to be text.
pub fn is_buffer_binary(buffer: &[u8]) -> bool {
matches!(inspect(buffer), ContentType::BINARY)
}
/// Checks if a file's content appears to be binary by reading a small chunk.
/// Returns true if the file appears to be binary, false if it appears to be text.
/// Returns true if the file cannot be read.
pub fn is_file_content_binary(path: impl AsRef<Path>) -> bool {
const CHUNK_SIZE: usize = 1024;
let Ok(mut file) = File::open(path) else {
return true;
};
let mut buffer = [0u8; CHUNK_SIZE];
let Ok(n) = file.read(&mut buffer) else {
return true;
};
is_buffer_binary(&buffer[..n])
}
/// Checks if a file is a binary file that should not be opened in Warp.
/// Note that we only check the file extension, not the file content.
/// Returns true for common binary file extensions like images, videos, executables, etc.
pub fn is_binary_file(path: impl AsRef<Path>) -> bool {
let path = path.as_ref();
match path.extension() {
Some(ext) => {
if let Some(ext) = ext.to_str() {
matches!(
ext.to_lowercase().as_str(),
"jpg"
| "jpeg"
| "png"
| "gif"
| "bmp"
| "tiff"
| "tif"
| "webp"
| "ico"
| "pdf"
| "doc"
| "docx"
| "xls"
| "xlsx"
| "ppt"
| "pptx"
| "odt"
| "ods"
| "odp"
| "zip"
| "tar"
| "gz"
| "bz2"
| "xz"
| "7z"
| "rar"
| "dmg"
| "iso"
| "img"
| "exe"
| "msi"
| "deb"
| "rpm"
| "app"
| "pkg"
| "bin"
| "so"
| "dll"
| "dylib"
| "mp3"
| "mp4"
| "avi"
| "mov"
| "wmv"
| "flv"
| "mkv"
| "wav"
| "flac"
| "ogg"
| "woff"
| "woff2"
| "ttf"
| "otf"
| "eot"
| "db"
| "sqlite"
| "sqlite3"
| "pyc"
| "pyo"
| "class"
| "jar"
)
} else {
false
}
}
None => path
.to_str()
.map(|path| !is_text_file(path))
.unwrap_or_default(),
}
}
/// Guess whether or not `path` is a Markdown file:
/// * Does it have a Markdown extension?
/// * Is it an extension-less file that's commonly Markdown.
pub fn is_markdown_file(path: impl AsRef<Path>) -> bool {
let path = path.as_ref();
match path.extension() {
Some(ext) => MARKDOWN_EXTENSIONS
.iter()
.any(|markdown_ext| ext.eq_ignore_ascii_case(markdown_ext)),
None => path.file_name().is_some_and(|file_name| {
MARKDOWN_FILE_NAMES
.iter()
.any(|markdown_name| file_name.eq_ignore_ascii_case(markdown_name))
}),
}
}
/// Determines if a file is likely to be a text file based on its filename.
///
/// This function uses a hybrid approach:
/// 1. First attempts MIME type detection via `mime_guess` for common cases
/// 2. Falls back to explicit extension checking for development-specific files
/// 3. Handles special cases like files without extensions that are commonly text
///
/// # Arguments
/// * `filename` - The filename or path to check
///
/// # Returns
/// `true` if the file is likely to be a text file, `false` otherwise
fn is_text_file(filename: &str) -> bool {
// Use mime_guess for initial detection
let mime = mime_guess::from_path(filename).first_or_octet_stream();
// Check if it's explicitly a text MIME type
if mime.type_() == mime::TEXT {
return true;
}
// Check for common application types that are actually text
if mime.type_() == mime::APPLICATION {
let subtype = mime.subtype().as_str();
if matches!(
subtype,
"json"
| "xml"
| "javascript"
| "yaml"
| "toml"
| "x-yaml"
| "x-toml"
| "x-javascript"
| "x-sh"
| "x-shellscript"
| "x-httpd-php"
| "x-ruby"
| "x-python"
| "x-perl"
| "sql"
) {
return true;
}
}
// Get the file extension for fallback checking
let extension = Path::new(filename)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_lowercase();
// Explicit extension checking for development files that might not be caught by MIME
if is_development_text_extension(&extension) {
return true;
}
// Handle files without extensions that are commonly text
if extension.is_empty() {
return is_extensionless_text_file(filename);
}
false
}
/// Checks if a file extension corresponds to a development-related text file.
fn is_development_text_extension(extension: &str) -> bool {
matches!(
extension,
// Programming languages not always caught by MIME
"rs" | "go" | "py" | "py3" | "pyw" | "pyi" | "js" | "mjs" | "cjs" |
"ts" | "tsx" | "jsx" | "java" | "c" | "cc" | "cpp" | "cxx" |
"h" | "hh" | "hpp" | "hxx" | "cs" | "php" | "phtml" | "rb" | "swift" |
"kt" | "kts" | "scala" | "sh" | "bash" | "zsh" | "fish" |
"ps1" | "bat" | "cmd" | "asm" | "s" | "vb" | "pl" | "r" |
"m" | "mm" | "dart" | "lua" | "vim" | "el" | "clj" | "cljs" |
"hs" | "lhs" | "ml" | "mli" | "fs" | "fsi" | "fsx" | "ex" |
"exs" | "erl" | "hrl" | "elm" | "nim" | "cr" | "zig" | "v" |
"jl" | "rkt" | "scm" | "lisp" | "cl" | "coffee" | "purs" |
"reason" | "re" | "res" | "resi" |
// Web technologies
"html" | "htm" | "css" | "scss" | "sass" | "less" | "vue" |
"svelte" | "astro" | "blade" | "twig" | "mustache" | "hbs" |
"handlebars" | "ejs" | "pug" | "jade" | "erb" | "haml" |
// Configuration and data formats
"toml" | "yaml" | "yml" | "json" | "jsonc" | "json5" |
"xml" | "ini" | "cfg" | "conf" | "config" | "properties" |
"env" | "dotenv" | "editorconfig" | "gitignore" | "gitattributes" |
// Documentation
"md" | "markdown" | "mdown" | "mkd" | "rst" | "txt" |
"rtf" | "tex" | "latex" | "adoc" | "asciidoc" | "org" |
"pod" | "rdoc" | "textile" | "wiki" | "mediawiki" |
// Build and project files
"cmake" | "gradle" | "sbt" | "ant" | "maven" | "pom" |
"build" | "mk" | "mak" | "ninja" | "bazel" | "bzl" |
"dockerfile" | "containerfile" |
// Package manager files
"lock" | "sum" | "mod" |
// Development tools config
"prettierrc" | "eslintrc" | "stylelintrc" | "babelrc" |
"postcssrc" | "browserslistrc" | "npmrc" | "yarnrc" |
"nvmrc" | "rvmrc" | "gemfile" | "podfile" | "cartfile" |
// Log and temporary files
"log" | "diff" | "patch" | "bak" | "tmp" | "temp" |
// Other common text formats
"csv" | "tsv" | "sql" | "graphql" | "gql" | "proto" |
"thrift" | "avro" | "schema" | "xsd" | "dtd" | "rng" |
"rnc" | "wsdl" | "wadl"
)
}
/// Checks if a file without an extension is commonly a text file.
fn is_extensionless_text_file(filename: &str) -> bool {
let basename = Path::new(filename)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(filename)
.to_lowercase();
matches!(basename.as_str(),
// Common files without extensions
"readme" | "license" | "licence" | "changelog" | "changes" |
"authors" | "contributors" | "copying" | "install" |
"news" | "todo" | "fixme" | "bugs" | "issues" | "release" |
"history" | "version" | "notice" | "disclaimer" |
// Build files
"makefile" | "dockerfile" | "containerfile" | "rakefile" |
"gemfile" | "podfile" | "cartfile" | "brewfile" |
// Config files
"procfile" | "profile" | "bashrc" | "zshrc" | "vimrc" |
"tmux.conf" | "gitconfig" | "hgrc" |
// Package files
"cargo.toml" | "package.json" | "composer.json" |
"pubspec.yaml" | "pyproject.toml"
) ||
// Handle dot-prefixed config files
basename.starts_with('.') && matches!(basename.as_str(),
".gitignore" | ".gitattributes" | ".editorconfig" |
".prettierrc" | ".eslintrc" | ".stylelintrc" | ".babelrc" |
".postcssrc" | ".browserslistrc" | ".npmrc" | ".yarnrc" |
".nvmrc" | ".rvmrc" | ".env" | ".envrc" | ".profile" |
".bashrc" | ".zshrc" | ".vimrc" | ".tmux.conf"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_common_text_files() {
// Programming languages
assert!(is_text_file("main.rs"));
assert!(is_text_file("script.py"));
assert!(is_text_file("app.js"));
assert!(is_text_file("component.tsx"));
assert!(is_text_file("Main.java"));
assert!(is_text_file("header.h"));
assert!(is_text_file("script.sh"));
// Web files
assert!(is_text_file("index.html"));
assert!(is_text_file("styles.css"));
assert!(is_text_file("component.vue"));
// Configuration files
assert!(is_text_file("config.json"));
assert!(is_text_file("settings.yaml"));
assert!(is_text_file("Cargo.toml"));
assert!(is_text_file(".gitignore"));
assert!(is_text_file(".env"));
// Documentation
assert!(is_text_file("README.md"));
assert!(is_text_file("docs.txt"));
assert!(is_text_file("manual.rst"));
// Build files
assert!(is_text_file("Dockerfile"));
assert!(is_text_file("Makefile"));
assert!(is_text_file("build.gradle"));
// Files without extensions
assert!(is_text_file("README"));
assert!(is_text_file("LICENSE"));
assert!(is_text_file("Dockerfile"));
}
#[test]
fn test_binary_files() {
// Images
assert!(!is_text_file("image.png"));
assert!(!is_text_file("photo.jpg"));
assert!(!is_text_file("icon.ico"));
// Note: SVG might be detected as text by MIME, which is correct
// Executables
assert!(!is_text_file("program.exe"));
assert!(!is_text_file("app.dmg"));
// Archives
assert!(!is_text_file("archive.zip"));
assert!(!is_text_file("package.tar.gz"));
assert!(!is_text_file("data.7z"));
// Media files
assert!(!is_text_file("video.mp4"));
assert!(!is_text_file("audio.mp3"));
assert!(!is_text_file("sound.wav"));
// Document formats (binary)
assert!(!is_text_file("document.pdf"));
assert!(!is_text_file("spreadsheet.xlsx"));
assert!(!is_text_file("presentation.pptx"));
}
#[test]
fn test_edge_cases() {
// Empty filename
assert!(!is_text_file(""));
// Files with multiple extensions
assert!(is_text_file("backup.tar.gz.txt"));
assert!(is_text_file("config.local.json"));
// Mixed case
assert!(is_text_file("Component.TSX"));
assert!(is_text_file("README.MD"));
// Path separators
assert!(is_text_file("/path/to/file.rs"));
assert!(is_text_file("..\\windows\\path\\file.py"));
// Unusual but valid text files
assert!(is_text_file("script.fish"));
assert!(is_text_file("data.graphql"));
assert!(is_text_file("schema.proto"));
}
#[test]
fn test_development_extensions() {
// Test some specific development file types
assert!(is_development_text_extension("rs"));
assert!(is_development_text_extension("py"));
assert!(is_development_text_extension("dockerfile"));
assert!(is_development_text_extension("yaml"));
assert!(!is_development_text_extension("png"));
assert!(!is_development_text_extension("exe"));
assert!(!is_development_text_extension("zip"));
}
#[test]
fn test_extensionless_files() {
assert!(is_extensionless_text_file("README"));
assert!(is_extensionless_text_file("LICENSE"));
assert!(is_extensionless_text_file("Dockerfile"));
assert!(is_extensionless_text_file(".gitignore"));
assert!(is_extensionless_text_file(".env"));
assert!(!is_extensionless_text_file("binary"));
assert!(!is_extensionless_text_file("unknown"));
assert!(!is_extensionless_text_file("data"));
}
}
+17
View File
@@ -0,0 +1,17 @@
//! This crate contains generic utilities and helpers available for use across all internal warp
//! crates.
//!
//! Generally, if a given function/abstraction is useful outside of a single warp-internal crate
//! but isn't large/complex enough to warrant its own crate, it belongs here.
pub mod assets;
pub mod content_version;
pub mod file;
pub mod file_type;
pub mod on_cancel;
pub mod path;
pub mod standardized_path;
pub mod user_input;
pub mod worktree_names;
#[cfg(windows)]
pub mod windows;
+64
View File
@@ -0,0 +1,64 @@
use pin_project::{pin_project, pinned_drop};
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
/// Trait allowing you to attach a function to a [`Future`] that will be called if the future is
/// cancelled.
pub trait OnCancelFutureExt
where
Self: Future + Sized,
{
/// Wraps the future with an [`OnCancelFutureExt`] that will execute the given function
/// when the future is cancelled.
fn on_cancel<D: FnMut()>(self, on_drop: D) -> OnCancelFuture<Self, D>;
}
impl<F: Future> OnCancelFutureExt for F {
fn on_cancel<D: FnMut()>(self, on_cancel: D) -> OnCancelFuture<Self, D> {
OnCancelFuture {
inner: self,
on_cancel,
is_ready: false,
}
}
}
/// Wrapper around a [`Future`] that calls an `on_cancel` callback if the future is cancelled
/// before it resolved to ready. See [`OnCancelFuture::on_cancel`] for more details. A future is
/// considered cancelled if it is dropped before resolving to [`Poll::Ready`], see <https://google.github.io/comprehensive-rust/async/pitfalls/cancellation.html#cancellation>.
#[pin_project(PinnedDrop)]
pub struct OnCancelFuture<F: Future, D: FnMut()> {
#[pin]
inner: F,
/// Function that is called when the future is cancelled.
on_cancel: D,
/// Whether the inner future is has returned [`Poll::Ready`] (indicating the future is
/// complete).
is_ready: bool,
}
impl<F: Future, D: FnMut()> Future for OnCancelFuture<F, D> {
type Output = F::Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<F::Output> {
let this = self.project();
let output = this.inner.poll(cx);
*this.is_ready = output.is_ready();
output
}
}
#[pinned_drop]
impl<F: Future, D: FnMut()> PinnedDrop for OnCancelFuture<F, D> {
fn drop(self: Pin<&mut Self>) {
// If the future was dropped before it was resolved to ready, the future was cancelled.
let this = self.project();
if !*this.is_ready {
(this.on_cancel)();
}
}
}
#[cfg(test)]
#[path = "on_cancel_tests.rs"]
mod tests;
+32
View File
@@ -0,0 +1,32 @@
use crate::on_cancel::OnCancelFutureExt;
use futures_util::future::{AbortHandle, Abortable, Aborted};
use std::sync::atomic::{AtomicBool, Ordering};
#[test]
fn test_ready_future_doesnt_call_callback() {
let callback_called = AtomicBool::new(false);
let future = async {}.on_cancel(|| callback_called.store(true, Ordering::SeqCst));
galaxyui::r#async::block_on(future);
assert!(!callback_called.load(Ordering::Relaxed));
}
#[test]
fn test_aborted_future_calls_callback() {
let callback_called = AtomicBool::new(false);
let (handle, registration) = AbortHandle::new_pair();
let future = Abortable::new(
async {}.on_cancel(|| callback_called.store(true, Ordering::SeqCst)),
registration,
);
// Abort the future before it is ever polled.
handle.abort();
let future_result = galaxyui::r#async::block_on(future);
// The future should be aborted and the callback should have been called.
assert_eq!(future_result, Err(Aborted));
assert!(callback_called.load(Ordering::Relaxed));
}
+851
View File
@@ -0,0 +1,851 @@
//! 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<String> =
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<Regex> = 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<String> {
// 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<LineAndColumnArg>,
}
#[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<usize>,
}
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<PathBuf, TargetDirError> {
// 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(<PathBuf as TryFrom<TypedPathBuf>>::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<PathBuf, MSYS2PathConversionError> {
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::<Vec<_>>();
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(<PathBuf as TryFrom<TypedPathBuf>>::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<PathBuf, WSLPathConversionError> {
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::<Vec<_>>();
let windows_path = 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]) =>
{
let mut windows_path = TypedPathBuf::new(PathType::Windows);
windows_path.push([*bytes, b":\\"].concat());
for component in unix_path.with_windows_encoding().components().skip(3) {
windows_path.push(component.as_bytes());
}
windows_path
}
_ => {
let mut windows_path = TypedPathBuf::new(PathType::Windows);
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());
}
windows_path
}
};
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),
}
}
#[cfg(windows)]
fn prefix(path: &Path) -> Option<std::path::Prefix<'_>> {
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<String> {
// 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)]
{
use std::path::Component;
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<P>(paths: impl IntoIterator<Item = P>) -> Option<PathBuf>
where
P: AsRef<Path>,
{
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::<PathBuf>();
// 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<P> {
/// Ancestor-deduped set of roots. The input order of surviving
/// entries is preserved.
pub roots: Vec<P>,
/// 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<P, Vec<P>>,
}
/// 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<P: RootPath>(roots: &[P]) -> RootGrouping<P> {
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<P> = 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<P> = Vec::new();
// Index in `deduped` -> closest surviving ancestor, if absorbed.
let mut absorbed_ancestor_by_index: HashMap<usize, P> = 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<P> = Vec::new();
let mut absorbed_by_root: HashMap<P, Vec<P>> = 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_test.rs"]
mod tests;
+810
View File
@@ -0,0 +1,810 @@
use super::*;
#[test]
fn test_user_friendly_path_with_home() {
let home = "/Users/blue";
assert_eq!(
user_friendly_path("/Users/blue", Some(home)),
"~".to_string(),
);
assert_eq!(
user_friendly_path("/Users/blue/warp", Some(home)),
"~/warp".to_string(),
);
assert_eq!(
user_friendly_path("/Users/admin/warp", Some(home)),
"/Users/admin/warp".to_string(),
);
}
#[test]
fn test_to_relative_path() {
use super::to_relative_path;
use std::path::Path;
// Basic relative path conversion
#[cfg(not(windows))]
{
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/projects/app/src/main.rs"),
Path::new("/Users/john/projects")
),
Some("app/src/main.rs".to_string())
);
// Same directory
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/projects"),
Path::new("/Users/john/projects")
),
Some(".".to_string())
);
// Parent directory
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john"),
Path::new("/Users/john/projects")
),
Some("..".to_string())
);
// Nested parent
assert_eq!(
to_relative_path(
false,
Path::new("/Users"),
Path::new("/Users/john/projects")
),
Some("../..".to_string())
);
// Cross-branch paths
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/documents/file.txt"),
Path::new("/Users/john/projects")
),
Some("../documents/file.txt".to_string())
);
// Root to subdirectory
assert_eq!(
to_relative_path(false, Path::new("/var/log/system.log"), Path::new("/")),
Some("var/log/system.log".to_string())
);
// Handles paths that would have leading slashes correctly
assert_eq!(
to_relative_path(false, Path::new("/home/user/file.txt"), Path::new("/home")),
Some("user/file.txt".to_string())
);
// Test with current directory references that should be cleaned
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/projects/./app/src/main.rs"),
Path::new("/Users/john/projects")
),
Some("app/src/main.rs".to_string()),
);
}
#[cfg(windows)]
{
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/projects/app/src/main.rs"),
Path::new("/Users/john/projects")
),
Some("app\\src\\main.rs".to_string())
);
// Same directory
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/projects"),
Path::new("/Users/john/projects")
),
Some(".".to_string())
);
// Parent directory
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john"),
Path::new("/Users/john/projects")
),
Some("..".to_string())
);
// Nested parent
assert_eq!(
to_relative_path(
false,
Path::new("/Users"),
Path::new("/Users/john/projects")
),
Some("..\\..".to_string())
);
// Cross-branch paths
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/documents/file.txt"),
Path::new("/Users/john/projects")
),
Some("..\\documents\\file.txt".to_string())
);
// Root to subdirectory
assert_eq!(
to_relative_path(false, Path::new("/var/log/system.log"), Path::new("/")),
Some("var\\log\\system.log".to_string())
);
// Handles paths that would have leading slashes correctly
assert_eq!(
to_relative_path(false, Path::new("/home/user/file.txt"), Path::new("/home")),
Some("user\\file.txt".to_string())
);
// Test with current directory references that should be cleaned
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/projects/./app/src/main.rs"),
Path::new("/Users/john/projects")
),
Some("app\\src\\main.rs".to_string())
);
// Windows paths - different drives should return None
assert_eq!(
to_relative_path(
/* is_wsl */ false,
Path::new("D:\\projects\\app"),
Path::new("C:\\workspace")
),
None,
);
// Windows paths - same drive
assert_eq!(
to_relative_path(
/* is_wsl */ false,
Path::new("C:\\projects\\app\\src\\main.rs"),
Path::new("C:\\projects")
),
Some("app\\src\\main.rs".to_string())
);
// Windows paths - same drive -- WSL is disabled for now
assert_eq!(
to_relative_path(
/* is_wsl */ true,
Path::new("C:\\projects\\app\\src\\main.rs"),
Path::new("C:\\projects")
),
None
);
}
}
#[test]
fn test_normalize_relative_path_for_glob() {
use std::path::Path;
assert_eq!(
normalize_relative_path_for_glob(Path::new("app/src/main.rs")),
"app/src/main.rs"
);
assert_eq!(
normalize_relative_path_for_glob(Path::new("./app/src/main.rs")),
"app/src/main.rs"
);
assert_eq!(
normalize_relative_path_for_glob(Path::new("../app/src/main.rs")),
"app/src/main.rs"
);
assert_eq!(normalize_relative_path_for_glob(Path::new("..")), "");
assert_eq!(normalize_relative_path_for_glob(Path::new("")), "");
}
#[test]
fn test_posix_escape() {
let shell_family = ShellFamily::Posix;
assert_eq!(
shell_family.escape("~/test_dir/library% 1$2"),
"\\~/test_dir/library%\\ 1\\$2"
);
assert_eq!(shell_family.escape("あい"), "あい");
assert_eq!(shell_family.escape("abc \n \t"), "abc\\ \\\n\\ \\\t");
assert_eq!(shell_family.escape(""), "''");
assert_eq!(
shell_family.escape("foo '\"' bar"),
"foo\\ \\'\\\"\\'\\ bar"
);
}
#[test]
fn test_powershell_escape() {
let shell_family = ShellFamily::PowerShell;
assert_eq!(
shell_family.escape("~/test_dir/library% 1$2"),
"~/test_dir/library%` 1`$2"
);
assert_eq!(shell_family.escape("あい"), "あい");
assert_eq!(shell_family.escape("abc \n \t"), "abc` `\n` `\t");
assert_eq!(shell_family.escape(""), "''");
assert_eq!(shell_family.escape("foo '\"' bar"), "foo` `'`\"`'` bar");
}
#[test]
fn test_posix_unescape() {
let shell_family = ShellFamily::Posix;
// Escaped spaces
assert_eq!(shell_family.unescape("my\\ file.txt"), "my file.txt");
// Multiple escaped characters
assert_eq!(
shell_family.unescape("path/to/my\\ file\\ \\(1\\).txt"),
"path/to/my file (1).txt"
);
// No escaping needed — returns borrowed
assert!(matches!(
shell_family.unescape("simple.txt"),
std::borrow::Cow::Borrowed(_)
));
// Trailing backslash kept as-is
assert_eq!(shell_family.unescape("trailing\\"), "trailing\\");
// Roundtrip: unescape(escape(x)) == x
let original = "hello world $HOME 'quotes'";
assert_eq!(
shell_family.unescape(&shell_family.escape(original)),
original
);
}
#[test]
fn test_powershell_unescape() {
let shell_family = ShellFamily::PowerShell;
// Escaped spaces
assert_eq!(shell_family.unescape("my` file.txt"), "my file.txt");
// Multiple escaped characters
assert_eq!(shell_family.unescape("path` `$var"), "path $var");
// No escaping needed — returns borrowed
assert!(matches!(
shell_family.unescape("simple.txt"),
std::borrow::Cow::Borrowed(_)
));
// Roundtrip: unescape(escape(x)) == x
let original = "hello world $HOME";
assert_eq!(
shell_family.unescape(&shell_family.escape(original)),
original
);
}
#[test]
fn test_clean_path() {
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml:10:5"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 10,
column_num: Some(5)
}),
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml:30:5abc"),
CleanPathResult {
path: "Cargo.toml:30:5abc".into(),
line_and_column_num: None
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml[30,5]"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 30,
column_num: Some(5)
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml(3,1)"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 3,
column_num: Some(1)
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml\", line 100, in"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 100,
column_num: None,
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml\", line 5, column 20"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 5,
column_num: Some(20),
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml#L100"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 100,
column_num: None
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml#L100:4"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 100,
column_num: Some(4)
})
}
);
// Line range format :start-end (should link to start line, ignore end line)
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml:10-50"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 10,
column_num: None
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("/path/to/file.rs:1-1000"),
CleanPathResult {
path: "/path/to/file.rs".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 1,
column_num: None
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("src/main.rs:100-100"),
CleanPathResult {
path: "src/main.rs".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 100,
column_num: None
})
}
);
}
#[test]
#[cfg(windows)]
fn test_msys2_exe_to_root() {
assert_eq!(
msys2_exe_to_root(WindowsPath::new(r"D:\Program Files\Git\usr\bin\git.exe")),
WindowsPathBuf::from(r"D:\Program Files\Git")
);
assert_eq!(
msys2_exe_to_root(WindowsPath::new(r"C:\git.exe")),
WindowsPathBuf::from(r"C:\Program Files\Git")
);
assert_eq!(
msys2_exe_to_root(WindowsPath::new(r"C:\foo\bar\baz\git.exe")),
WindowsPathBuf::from(r"C:\Program Files\Git")
);
assert_eq!(
msys2_exe_to_root(WindowsPath::new(r"C:\msys64\usr\bin\fish.exe")),
WindowsPathBuf::from(r"C:\msys64")
);
}
/// These tests all fail when running on UNIX.
#[test]
#[cfg(windows)]
fn test_convert_git_bash_to_windows_native_path() {
use std::sync::LazyLock;
static GIT_BASH_ROOT: LazyLock<WindowsPathBuf> =
LazyLock::new(|| WindowsPathBuf::from(r"C:\Program Files\Git"));
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/c/foo/bar").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"C:\foo\bar")
);
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/d/special folder").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"D:\special folder")
);
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/z").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"Z:\")
);
// non-ascii isn't actually a valid drive name
assert!(matches!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/😊/invalid").to_path(),
&GIT_BASH_ROOT
),
Err(MSYS2PathConversionError::NotInDrive)
));
assert!(matches!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/aa/invalid").to_path(),
&GIT_BASH_ROOT
),
Err(MSYS2PathConversionError::NotInDrive)
));
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("//wsl$/Ubuntu/home").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"\\wsl$\Ubuntu\home")
);
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("//WSL.localhost/Ubuntu/home").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"\\WSL.localhost\Ubuntu\home")
);
// This path might get auto-inferred by typed-path to be a Windows path, even if it looks like
// UNIX with forward slashes.
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPath::from("//wsl$/Ubuntu/home"),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"\\wsl$\Ubuntu\home")
);
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("~/.bash_history").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"~\.bash_history")
);
// Relative paths cannot be converted.
assert!(matches!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("some/relative/path").to_path(),
&GIT_BASH_ROOT
),
Err(MSYS2PathConversionError::PathNotAbsolute)
));
assert!(matches!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_windows(r"C:\Users").to_path(),
&GIT_BASH_ROOT
),
Err(MSYS2PathConversionError::NonUnixPath)
));
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"C:\Program Files\Git")
);
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/usr/bin").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"C:\Program Files\Git\usr\bin")
);
}
/// These tests all fail when running on UNIX.
#[test]
#[cfg(windows)]
fn test_convert_wsl_to_windows_host_path() {
assert_eq!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("/mnt/c/foo/bar").to_path(),
"Ubuntu"
)
.unwrap(),
PathBuf::from(r"C:\foo\bar")
);
assert_eq!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("/mnt/e/special dir").to_path(),
"Ubuntu"
)
.unwrap(),
PathBuf::from(r"E:\special dir")
);
assert_eq!(
convert_wsl_to_windows_host_path(&TypedPathBuf::from_unix("/mnt/z").to_path(), "Ubuntu")
.unwrap(),
PathBuf::from(r"Z:\")
);
assert_eq!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("/home/andy").to_path(),
"Ubuntu"
)
.unwrap(),
PathBuf::from(r"\\WSL$\Ubuntu\home\andy")
);
assert!(matches!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("some/relative/path").to_path(),
"Ubuntu"
),
Err(WSLPathConversionError::PathNotAbsolute)
));
assert!(matches!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("~/.bash_history").to_path(),
"Ubuntu"
),
Err(WSLPathConversionError::PathNotAbsolute)
));
// Two letters isn't actually a valid drive.
assert_eq!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("/mnt/aa/invalid_drive").to_path(),
"Ubuntu"
)
.unwrap(),
PathBuf::from(r"\\WSL$\Ubuntu\mnt\aa\invalid_drive")
);
assert_eq!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("/mnt/😊/invalid_drive").to_path(),
"Ubuntu"
)
.unwrap(),
PathBuf::from(r"\\WSL$\Ubuntu\mnt\😊\invalid_drive")
);
assert!(matches!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_windows(r"C:\Users").to_path(),
"Ubuntu"
),
Err(WSLPathConversionError::NonUnixPath)
));
}
#[test]
fn test_convert_windows_path_to_wsl() {
assert_eq!(
convert_windows_path_to_wsl(r"C:\Users\aloke\file.txt"),
"/mnt/c/Users/aloke/file.txt"
);
assert_eq!(
convert_windows_path_to_wsl(r"D:\Pictures\Screenshots\Screenshot 2025-05-14 155816.png"),
"/mnt/d/Pictures/Screenshots/Screenshot 2025-05-14 155816.png"
);
// Drive letter only
assert_eq!(convert_windows_path_to_wsl(r"C:\"), "/mnt/c");
assert_eq!(convert_windows_path_to_wsl("C:"), "/mnt/c");
// Uppercase drive letter should be lowercased
assert_eq!(convert_windows_path_to_wsl(r"E:\foo"), "/mnt/e/foo");
// Non-drive path (e.g. UNC) gets backslashes replaced
assert_eq!(
convert_windows_path_to_wsl(r"\\server\share\file"),
"//server/share/file"
);
}
#[test]
fn test_convert_windows_path_to_msys2() {
assert_eq!(
convert_windows_path_to_msys2(r"C:\Users\aloke\file.txt"),
"/c/Users/aloke/file.txt"
);
assert_eq!(
convert_windows_path_to_msys2(r"D:\Pictures\Screenshots\Screenshot 2025-05-14 155816.png"),
"/d/Pictures/Screenshots/Screenshot 2025-05-14 155816.png"
);
// Drive letter only
assert_eq!(convert_windows_path_to_msys2(r"C:\"), "/c");
assert_eq!(convert_windows_path_to_msys2("C:"), "/c");
// Uppercase drive letter should be lowercased
assert_eq!(convert_windows_path_to_msys2(r"E:\foo"), "/e/foo");
// Non-drive path (e.g. UNC) gets backslashes replaced
assert_eq!(
convert_windows_path_to_msys2(r"\\server\share\file"),
"//server/share/file"
);
}
#[test]
fn test_canonicalize_git_bash_path() {
assert_eq!(
canonicalize_git_bash_path(
Path::new("C:")
.join("Program Files")
.join("Git")
.join("bin")
.join("bash.exe")
),
Path::new("C:")
.join("Program Files")
.join("Git")
.join("usr")
.join("bin")
.join("bash.exe")
);
assert_eq!(
canonicalize_git_bash_path(
Path::new("C:")
.join("Windows")
.join("system32")
.join("bash.exe")
),
Path::new("C:")
.join("Windows")
.join("system32")
.join("bash.exe")
);
}
// ── group_roots_by_common_ancestor tests ─────────────────────────────
mod group_roots_by_common_ancestor_tests {
use crate::path::group_roots_by_common_ancestor;
use std::path::PathBuf;
fn pb(s: &str) -> PathBuf {
PathBuf::from(s)
}
#[test]
fn empty_input_produces_empty_grouping() {
let grouping = group_roots_by_common_ancestor::<PathBuf>(&[]);
assert!(grouping.roots.is_empty());
assert!(grouping.absorbed_by_root.is_empty());
}
#[test]
fn single_path_survives_with_no_absorbed() {
let grouping = group_roots_by_common_ancestor(&[pb("/a")]);
assert_eq!(grouping.roots, vec![pb("/a")]);
assert!(grouping.absorbed_by_root.is_empty());
}
#[test]
fn unrelated_siblings_both_survive() {
let grouping = group_roots_by_common_ancestor(&[pb("/a"), pb("/b")]);
assert_eq!(grouping.roots, vec![pb("/a"), pb("/b")]);
assert!(grouping.absorbed_by_root.is_empty());
}
#[test]
fn descendant_absorbed_into_ancestor() {
// Ancestor listed first.
let grouping = group_roots_by_common_ancestor(&[pb("/a"), pb("/a/b")]);
assert_eq!(grouping.roots, vec![pb("/a")]);
assert_eq!(grouping.absorbed_by_root.len(), 1);
assert_eq!(grouping.absorbed_by_root[&pb("/a")], vec![pb("/a/b")]);
}
#[test]
fn descendant_first_still_absorbed() {
// Descendant listed first, ancestor second; survivor is still the
// ancestor and its input order is preserved.
let grouping = group_roots_by_common_ancestor(&[pb("/a/b"), pb("/a")]);
assert_eq!(grouping.roots, vec![pb("/a")]);
assert_eq!(grouping.absorbed_by_root[&pb("/a")], vec![pb("/a/b")]);
}
#[test]
fn three_deep_chain_collapses_to_root() {
// Descendant order in input is preserved in the absorbed list.
let grouping = group_roots_by_common_ancestor(&[pb("/a/b/c"), pb("/a/b"), pb("/a")]);
assert_eq!(grouping.roots, vec![pb("/a")]);
assert_eq!(
grouping.absorbed_by_root[&pb("/a")],
vec![pb("/a/b/c"), pb("/a/b")]
);
}
#[test]
fn mixed_groups_absorb_independently() {
let grouping =
group_roots_by_common_ancestor(&[pb("/a"), pb("/x"), pb("/a/b"), pb("/x/y")]);
assert_eq!(grouping.roots, vec![pb("/a"), pb("/x")]);
assert_eq!(grouping.absorbed_by_root[&pb("/a")], vec![pb("/a/b")]);
assert_eq!(grouping.absorbed_by_root[&pb("/x")], vec![pb("/x/y")]);
}
#[test]
fn same_prefix_different_component_name_both_survive() {
// /foo/a is NOT an ancestor of /foo/abc (component-aware match).
let grouping = group_roots_by_common_ancestor(&[pb("/foo/a"), pb("/foo/abc")]);
assert_eq!(grouping.roots, vec![pb("/foo/a"), pb("/foo/abc")]);
assert!(grouping.absorbed_by_root.is_empty());
}
#[test]
fn duplicate_inputs_collapse_to_single_survivor() {
let grouping = group_roots_by_common_ancestor(&[pb("/a"), pb("/a")]);
assert_eq!(grouping.roots, vec![pb("/a")]);
assert!(grouping.absorbed_by_root.is_empty());
}
#[test]
fn surviving_root_order_matches_input_order() {
// Insert a descendant between two surviving ancestors; the survivors
// should appear in their original input order even though processing
// sorted by component count.
let grouping = group_roots_by_common_ancestor(&[pb("/b"), pb("/a/x"), pb("/a"), pb("/c")]);
assert_eq!(grouping.roots, vec![pb("/b"), pb("/a"), pb("/c")]);
assert_eq!(grouping.absorbed_by_root[&pb("/a")], vec![pb("/a/x")]);
}
#[test]
fn descendant_absorbed_by_closest_ancestor_not_furthest() {
// Both /a and /a/b are surviving ancestors of /a/b/c... wait, /a/b is
// itself absorbed into /a. So /a/b/c should be absorbed into /a as
// well (the only surviving ancestor).
let grouping = group_roots_by_common_ancestor(&[pb("/a"), pb("/a/b"), pb("/a/b/c")]);
assert_eq!(grouping.roots, vec![pb("/a")]);
assert_eq!(
grouping.absorbed_by_root[&pb("/a")],
vec![pb("/a/b"), pb("/a/b/c")]
);
}
}
+287
View File
@@ -0,0 +1,287 @@
//! A normalized, platform-aware path type that does not require filesystem I/O.
//!
//! [`StandardizedPath`] wraps [`TypedPathBuf`] and guarantees that the inner path is always
//! absolute and normalized (`.` and `..` segments removed, separators collapsed). Unlike
//! [`CanonicalizedPath`](repo_metadata::CanonicalizedPath), construction does **not** resolve
//! symlinks or verify existence on disk.
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use typed_path::{PathType, TypedPath, TypedPathBuf};
/// Error returned when a path cannot be converted into a [`StandardizedPath`].
#[derive(Debug, thiserror::Error)]
pub enum InvalidPathError {
#[error("path is not absolute: {0}")]
NotAbsolute(String),
#[error("path contains invalid UTF-8")]
InvalidUtf8,
}
/// A normalized, platform-aware path that does not require the file to exist.
///
/// Unlike `CanonicalizedPath`, construction does NOT perform filesystem I/O.
/// Normalization removes `.` and `..` segments and collapses separators, but
/// does not resolve symlinks or verify existence.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct StandardizedPath(TypedPathBuf);
impl StandardizedPath {
// ── Construction APIs ─────────────────────────────────────────────
/// Create from a string, inferring Unix vs Windows encoding.
/// Normalizes the path (removes `.`/`..`, collapses separators).
/// Returns an error if the path is not absolute.
pub fn try_new(path: &str) -> Result<Self, InvalidPathError> {
let typed = TypedPathBuf::from(path);
let normalized = typed.normalize();
if !normalized.is_absolute() {
return Err(InvalidPathError::NotAbsolute(path.to_owned()));
}
Ok(Self(normalized))
}
/// Create with an explicit path type (Unix or Windows).
/// Returns an error if the path is not absolute.
pub fn try_with_encoding(path: &str, path_type: PathType) -> Result<Self, InvalidPathError> {
let typed = TypedPathBuf::new(path_type);
let typed = typed.join(path);
let normalized = typed.normalize();
if !normalized.is_absolute() {
return Err(InvalidPathError::NotAbsolute(path.to_owned()));
}
Ok(Self(normalized))
}
/// Create from a local `std::path::Path`, inferring encoding from
/// the compile target. Normalizes but does NOT canonicalize.
/// Returns an error if the path is not absolute.
pub fn try_from_local(path: &Path) -> Result<Self, InvalidPathError> {
let path_str = path.to_str().ok_or(InvalidPathError::InvalidUtf8)?;
let typed = local_typed_path_buf(path_str);
let normalized = typed.normalize();
if !normalized.is_absolute() {
return Err(InvalidPathError::NotAbsolute(path_str.to_owned()));
}
Ok(Self(normalized))
}
/// Create from a local `std::path::Path` that is **known** to be absolute.
///
/// # Panics
/// Panics (debug-only) if the path is not absolute or contains invalid
/// UTF-8. In release builds the path is accepted as-is to avoid
/// to penalize hot paths.
pub fn from_local_absolute_unchecked(path: &Path) -> Self {
debug_assert!(
path.is_absolute(),
"from_local_absolute called with non-absolute path: {}",
path.display()
);
debug_assert!(
path.to_str().is_some(),
"from_local_absolute called with non-UTF-8 path: {}",
path.display()
);
let path_str = path.to_str().unwrap_or_default();
let typed = local_typed_path_buf(path_str);
Self(typed.normalize())
}
/// Create from a local path with full canonicalization (resolves
/// symlinks, verifies existence). This is the I/O-performing
/// equivalent of `CanonicalizedPath::try_from`.
/// Use at shell boundaries when receiving paths from the OS.
pub fn from_local_canonicalized(path: &Path) -> io::Result<Self> {
let canonical = dunce::canonicalize(path)?;
// dunce::simplified strips the UNC prefix when safe.
let simplified = dunce::simplified(&canonical);
let path_str = simplified.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"canonicalized path is not valid UTF-8",
)
})?;
let typed = local_typed_path_buf(path_str);
// Canonical paths are already normalized, but normalize anyway for consistency.
Ok(Self(typed.normalize()))
}
// ── Query APIs ───────────────────────────────────────────────────
/// Returns the underlying `TypedPath`.
pub fn as_typed_path(&self) -> TypedPath<'_> {
self.0.to_path()
}
/// Returns the string representation of the path.
pub fn as_str(&self) -> &str {
self.0.to_str().unwrap_or_default()
}
/// Returns the file name component, if any.
pub fn file_name(&self) -> Option<&str> {
self.0.file_name().and_then(|b| std::str::from_utf8(b).ok())
}
/// Returns the extension, if any.
pub fn extension(&self) -> Option<&str> {
self.0.extension().and_then(|b| std::str::from_utf8(b).ok())
}
/// Returns the parent path, if any.
pub fn parent(&self) -> Option<StandardizedPath> {
self.0.parent().map(|p| StandardizedPath(p.to_path_buf()))
}
/// Whether this path starts with the given prefix.
pub fn starts_with(&self, base: &StandardizedPath) -> bool {
self.0.starts_with(&base.0)
}
/// Whether this path ends with the given suffix (component-aware).
///
/// The suffix can be a relative path string (e.g. `.agents/skills`).
/// Matching is done at the component level, so `/repo/myskills` does
/// **not** match the suffix `skills`.
pub fn ends_with(&self, suffix: &str) -> bool {
self.0.ends_with(suffix)
}
/// Strip a prefix from this path, returning the relative remainder.
pub fn strip_prefix(&self, base: &StandardizedPath) -> Option<&str> {
let self_str = self.as_str();
let base_str = base.as_str();
self_str.strip_prefix(base_str).map(|remainder| {
// Remove leading separator if present.
remainder
.strip_prefix('/')
.or_else(|| remainder.strip_prefix('\\'))
.unwrap_or(remainder)
})
}
/// Join a relative segment onto this path.
pub fn join(&self, segment: &str) -> StandardizedPath {
StandardizedPath(self.0.join(segment).normalize())
}
/// Whether the path uses Unix encoding.
pub fn is_unix(&self) -> bool {
self.0.to_path().is_unix()
}
/// Whether the path uses Windows encoding.
pub fn is_windows(&self) -> bool {
self.0.to_path().is_windows()
}
/// Sets the file name component of this path, analogous to
/// [`PathBuf::set_file_name`].
pub fn set_file_name(&mut self, name: &str) {
self.0.set_file_name(name);
self.0 = self.0.normalize();
}
/// Returns an iterator over the ancestors of this path, starting with
/// the path itself and ending at the root.
pub fn ancestors(&self) -> impl Iterator<Item = StandardizedPath> {
let mut current = Some(self.clone());
std::iter::from_fn(move || {
let path = current.take()?;
current = path.parent();
Some(path)
})
}
// ── Conversion APIs ──────────────────────────────────────────────
/// Convert to a local `PathBuf` if the encoding matches the current OS.
/// Returns `None` for a Unix-encoded path on Windows or vice versa.
pub fn to_local_path(&self) -> Option<PathBuf> {
if encoding_matches_local(&self.0) {
Some(PathBuf::from(self.as_str()))
} else {
None
}
}
/// Converts this path to a local [`PathBuf`] by re-encoding its components
/// for the current OS.
///
/// **Use this only when the path is known to originate from the local
/// filesystem** (e.g. from [`LocalRepoMetadataModel`], [`Repository`],
/// [`DetectedRepositories`], or any path that was constructed via
/// [`from_local_canonicalized`](Self::from_local_canonicalized) /
/// [`try_from_local`](Self::try_from_local)). For those paths the
/// encoding already matches and the conversion is lossless.
///
/// If the path was constructed with a foreign encoding (e.g. a
/// Windows-encoded remote path on a macOS host), the conversion is lossy:
/// platform-specific prefixes like `C:` are dropped and separators are
/// translated. Prefer [`to_local_path`](Self::to_local_path) when the
/// encoding match is not guaranteed and you need to handle the mismatch
/// explicitly.
///
/// This function is generally something you shouldn't use. We are using this
/// as a stop gap to avoid `unwrap` as we migrate from PathBuf to StandardizedPath.
pub fn to_local_path_lossy(&self) -> PathBuf {
let local = if cfg!(windows) {
self.0.with_windows_encoding()
} else {
self.0.with_unix_encoding()
};
PathBuf::from(local.to_str().unwrap_or_default())
}
}
impl fmt::Display for StandardizedPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl Serialize for StandardizedPath {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.as_str().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for StandardizedPath {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::try_new(&s).map_err(serde::de::Error::custom)
}
}
// ── Helpers ──────────────────────────────────────────────────────────
/// Construct a `TypedPathBuf` using the local platform's encoding.
///
/// On Unix targets the path is always treated as Unix-encoded; on Windows
/// targets it is always treated as Windows-encoded. This avoids ambiguity
/// from the heuristic-based `TypedPathBuf::from` inference.
fn local_typed_path_buf(path_str: &str) -> TypedPathBuf {
if cfg!(windows) {
typed_path::WindowsPathBuf::from(path_str).to_typed_path_buf()
} else {
typed_path::UnixPathBuf::from(path_str).to_typed_path_buf()
}
}
/// Returns true if the `TypedPathBuf` encoding matches the compilation target.
fn encoding_matches_local(typed: &TypedPathBuf) -> bool {
let path = typed.to_path();
if cfg!(windows) {
path.is_windows()
} else {
path.is_unix()
}
}
#[cfg(test)]
#[path = "standardized_path_tests.rs"]
mod tests;
@@ -0,0 +1,152 @@
use std::path::Path;
use super::*;
#[test]
fn try_new_unix_absolute() {
let p = StandardizedPath::try_new("/home/user/project").unwrap();
assert_eq!(p.as_str(), "/home/user/project");
assert!(p.is_unix());
}
#[test]
fn try_new_windows_absolute() {
let p = StandardizedPath::try_new("C:\\Users\\user\\project").unwrap();
assert_eq!(p.as_str(), "C:\\Users\\user\\project");
assert!(p.is_windows());
}
#[test]
fn try_new_normalizes_dot_segments() {
let p = StandardizedPath::try_new("/home/user/./project/../project/src").unwrap();
assert_eq!(p.as_str(), "/home/user/project/src");
}
#[test]
fn try_new_rejects_relative() {
assert!(StandardizedPath::try_new("relative/path").is_err());
}
#[test]
fn try_from_local_absolute() {
// Use a platform-appropriate absolute path.
#[cfg(unix)]
let (input, expected) = (Path::new("/tmp/test"), "/tmp/test");
#[cfg(windows)]
let (input, expected) = (Path::new("C:\\Windows"), "C:\\Windows");
let p = StandardizedPath::try_from_local(input).unwrap();
assert_eq!(p.as_str(), expected);
}
#[test]
fn try_from_local_rejects_relative() {
assert!(StandardizedPath::try_from_local(Path::new("relative")).is_err());
}
#[test]
fn from_local_canonicalized_existing_path() {
// Use a path that exists on all platforms.
let existing = std::env::temp_dir();
let p = StandardizedPath::from_local_canonicalized(&existing).unwrap();
assert!(!p.as_str().is_empty());
// Encoding should match the local OS.
#[cfg(unix)]
assert!(p.is_unix());
#[cfg(windows)]
assert!(p.is_windows());
}
#[test]
fn from_local_canonicalized_nonexistent() {
#[cfg(unix)]
let path = Path::new("/nonexistent_path_xyz_123");
#[cfg(windows)]
let path = Path::new("C:\\nonexistent_path_xyz_123");
assert!(StandardizedPath::from_local_canonicalized(path).is_err());
}
#[test]
fn file_name() {
let p = StandardizedPath::try_new("/home/user/file.rs").unwrap();
assert_eq!(p.file_name(), Some("file.rs"));
}
#[test]
fn extension() {
let p = StandardizedPath::try_new("/home/user/file.rs").unwrap();
assert_eq!(p.extension(), Some("rs"));
}
#[test]
fn parent() {
let p = StandardizedPath::try_new("/home/user/file.rs").unwrap();
let parent = p.parent().unwrap();
assert_eq!(parent.as_str(), "/home/user");
}
#[test]
fn starts_with() {
let p = StandardizedPath::try_new("/home/user/project/src").unwrap();
let base = StandardizedPath::try_new("/home/user/project").unwrap();
assert!(p.starts_with(&base));
let other = StandardizedPath::try_new("/other").unwrap();
assert!(!p.starts_with(&other));
}
#[test]
fn strip_prefix() {
let p = StandardizedPath::try_new("/home/user/project/src/main.rs").unwrap();
let base = StandardizedPath::try_new("/home/user/project").unwrap();
assert_eq!(p.strip_prefix(&base), Some("src/main.rs"));
}
#[test]
fn join() {
let p = StandardizedPath::try_new("/home/user").unwrap();
let joined = p.join("project/src");
assert_eq!(joined.as_str(), "/home/user/project/src");
}
#[test]
fn to_local_path() {
// to_local_path returns Some only when encoding matches the OS.
let existing = std::env::temp_dir();
let p = StandardizedPath::from_local_canonicalized(&existing).unwrap();
let local = p.to_local_path();
assert!(local.is_some());
}
#[test]
#[cfg(unix)]
fn to_local_path_unix_on_unix() {
let p = StandardizedPath::try_new("/home/user").unwrap();
assert_eq!(p.to_local_path().unwrap(), Path::new("/home/user"));
}
#[test]
fn display() {
let p = StandardizedPath::try_new("/home/user/project").unwrap();
assert_eq!(format!("{p}"), "/home/user/project");
}
#[test]
fn serde_roundtrip() {
let p = StandardizedPath::try_new("/home/user/project").unwrap();
let json = serde_json::to_string(&p).unwrap();
assert_eq!(json, "\"/home/user/project\"");
let deserialized: StandardizedPath = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, p);
}
#[test]
fn equality_and_hash() {
use std::collections::HashSet;
let a = StandardizedPath::try_new("/home/user").unwrap();
let b = StandardizedPath::try_new("/home/user").unwrap();
assert_eq!(a, b);
let mut set = HashSet::new();
set.insert(a);
assert!(set.contains(&b));
}
+45
View File
@@ -0,0 +1,45 @@
use std::{
fmt::{self, Debug},
ops::{Deref, DerefMut},
};
/// Wrapper type for values which may contain user input.
///
/// Use this to prevent logging user input in production builds. In local development builds, the
/// value will still be shown.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct UserInput<T>(T);
impl<T> UserInput<T> {
pub fn new<U: Into<T>>(value: U) -> Self {
Self(value.into())
}
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> Deref for UserInput<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> DerefMut for UserInput<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<T: Debug> Debug for UserInput<T> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if cfg!(debug_assertions) {
f.debug_tuple("UserInput").field(&self.0).finish()
} else {
f.debug_struct("UserInput").finish_non_exhaustive()
}
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Windows-specific utilities.
/// Attaches the current process to the console of the parent process.
///
/// This is useful for command-line interfaces that need to ensure all standard
/// output gets printed correctly when run from a terminal.
pub fn attach_to_parent_console() {
use windows::Win32::System::Console::{AttachConsole, ATTACH_PARENT_PROCESS};
let _ = unsafe { AttachConsole(ATTACH_PARENT_PROCESS) };
}
+262
View File
@@ -0,0 +1,262 @@
use std::collections::HashSet;
use rand::seq::SliceRandom;
use rand::Rng;
/// Desert/southwest-themed words used to generate memorable worktree branch names.
/// Deduplicated across six categories.
const WORDS: &[&str] = &[
// ── Landforms & Terrain ─────────────────────────────────────────────────
"alcove",
"arch",
"arroyo",
"badlands",
"bajada",
"basin",
"bluff",
"bolson",
"butte",
"caldera",
"canyon",
"caprock",
"chasm",
"chimney",
"cinder",
"coulee",
"crag",
"crater",
"cuesta",
"dune",
"escarpment",
"flats",
"gap",
"gorge",
"gulch",
"hogback",
"inselberg",
"lava",
"ledge",
"malpais",
"mesa",
"mogote",
"monolith",
"notch",
"outcrop",
"pass",
"pediment",
"pinnacle",
"plateau",
"playa",
"ravine",
"ridge",
"rimrock",
"saddle",
"scree",
"spire",
"switchback",
"talus",
"tepui",
"wash",
// ── Desert Plants ───────────────────────────────────────────────────────
"agave",
"barrel",
"brittlebush",
"cactus",
"candelilla",
"chamisa",
"chaparral",
"cholla",
"claret",
"creosote",
"fishhook",
"hedgehog",
"ironwood",
"jojoba",
"joshua",
"juniper",
"lechuguilla",
"lupine",
"madrone",
"mallow",
"manzanita",
"mariposa",
"mesquite",
"ocotillo",
"organ-pipe",
"palo-verde",
"pinyon",
"prickly",
"rabbitbrush",
"sagebrush",
"saguaro",
"saltbush",
"sotol",
"tumbleweed",
"yucca",
// ── Desert Animals ──────────────────────────────────────────────────────
"armadillo",
"badger",
"bighorn",
"bobcat",
"burrowing-owl",
"centipede",
"coachwhip",
"cottontail",
"cougar",
"coyote",
"falcon",
"gecko",
"gila",
"hawk",
"horned-toad",
"jackrabbit",
"javelina",
"kingsnake",
"kit-fox",
"mule-deer",
"nighthawk",
"prairie-dog",
"pronghorn",
"quail",
"racer",
"rattler",
"ringtail",
"roadrunner",
"scorpion",
"sidewinder",
"swift",
"tarantula",
"thrasher",
"tortoise",
"vulture",
"wren",
// ── Minerals, Rocks & Metals ────────────────────────────────────────────
"agate",
"basalt",
"calcite",
"cinnabar",
"cobalt",
"copper",
"feldspar",
"flint",
"garnet",
"granite",
"gypsum",
"iron",
"jasper",
"limestone",
"malachite",
"mica",
"obsidian",
"onyx",
"opal",
"petrified",
"pumice",
"pyrite",
"quartz",
"sandstone",
"shale",
"tin",
"topaz",
"travertine",
"turquoise",
"zinc",
// ── Southwest Culture & Spanish ─────────────────────────────────────────
"acequia",
"adobe",
"cumbre",
"equinox",
"hacienda",
"latilla",
"luminaria",
"metate",
"mirador",
"nicho",
"olla",
"oz",
"petroglyph",
"pictograph",
"portal",
"ramada",
"rio",
"ristra",
"sierra",
"siesta",
"solstice",
"tierra",
"tinaja",
"viga",
// ── Weather & Sky ───────────────────────────────────────────────────────
"brushfire",
"corona",
"dawn",
"drought",
"dry-lightning",
"dusk",
"dust-devil",
"ember",
"firestorm",
"flash-flood",
"haze",
"mirage",
"monsoon",
"moonrise",
"shimmer",
"smoke",
"starlight",
"sundog",
"sundowner",
"thermal",
"twilight",
"wildfire",
"zephyr",
];
/// Maximum number of random attempts at a given word count before escalating.
const MAX_RETRIES_PER_LEVEL: usize = 2;
/// Maximum word count before falling back to a numeric suffix.
const MAX_WORD_COUNT: usize = 5;
/// Generates a name with `word_count` distinct random words joined by `-`.
/// Returns `Some(name)` if a name not in `existing` is found within
/// `MAX_RETRIES_PER_LEVEL` attempts, or `None` if all attempts collided.
fn generate_name(
word_count: usize,
existing: &HashSet<&str>,
rng: &mut impl Rng,
) -> Option<String> {
for _ in 0..MAX_RETRIES_PER_LEVEL {
let chosen: Vec<&str> = WORDS.choose_multiple(rng, word_count).copied().collect();
let name = chosen.join("-");
if !existing.contains(name.as_str()) {
return Some(name);
}
}
None
}
/// Generates a unique worktree branch name that does not collide with any name
/// in `existing`. Starts with 2-word names and escalates to more words on
/// collision. Accepts an explicit RNG for deterministic testing.
pub fn generate_unique_name(existing: &HashSet<&str>, rng: &mut impl Rng) -> String {
for word_count in 2..=MAX_WORD_COUNT {
if let Some(name) = generate_name(word_count, existing, rng) {
return name;
}
}
// Practically unreachable — 198^5 ≈ 2.9 × 10^11 possibilities.
// Fall back to a numeric suffix as a safety net.
let n: u32 = rng.gen();
format!("worktree-{n}")
}
/// Generates a unique worktree branch name using the thread-local RNG.
/// This is the primary entry point for call sites.
pub fn generate_worktree_branch_name(existing: &HashSet<&str>) -> String {
generate_unique_name(existing, &mut rand::thread_rng())
}
#[cfg(test)]
#[path = "worktree_names_tests.rs"]
mod tests;
@@ -0,0 +1,168 @@
use std::collections::HashSet;
use rand::prelude::StdRng;
use rand::SeedableRng;
use super::{generate_unique_name, WORDS};
fn seeded_rng(seed: u64) -> StdRng {
StdRng::seed_from_u64(seed)
}
#[test]
fn deterministic_output_with_seeded_rng() {
let existing = HashSet::new();
let a = generate_unique_name(&existing, &mut seeded_rng(42));
let b = generate_unique_name(&existing, &mut seeded_rng(42));
assert_eq!(a, b, "same seed should produce the same name");
}
#[test]
fn format_is_two_hyphenated_words() {
let existing = HashSet::new();
let name = generate_unique_name(&existing, &mut seeded_rng(1));
let parts: Vec<&str> = WORDS
.iter()
.copied()
.filter(|w| name.starts_with(&format!("{w}-")) || name.ends_with(&format!("-{w}")))
.collect();
// Instead of splitting on `-` (which would break hyphenated words like
// `palo-verde`), verify that the name starts with one word, ends with
// another, and those two words are distinct.
let word_set: HashSet<&str> = WORDS.iter().copied().collect();
let found_prefix = word_set
.iter()
.find(|w| name.starts_with(*w) && name.len() > w.len() && name.as_bytes()[w.len()] == b'-');
let found_suffix = word_set.iter().find(|w| {
name.ends_with(*w)
&& name.len() > w.len()
&& name.as_bytes()[name.len() - w.len() - 1] == b'-'
});
assert!(
found_prefix.is_some(),
"name should start with a word from WORDS: {name}"
);
assert!(
found_suffix.is_some(),
"name should end with a word from WORDS: {name}"
);
assert_ne!(
found_prefix.unwrap(),
found_suffix.unwrap(),
"the two words should be distinct: {name}"
);
assert!(
!parts.is_empty(),
"name should contain words from WORDS: {name}"
);
}
#[test]
fn words_are_distinct() {
let existing = HashSet::new();
for seed in 0..100 {
let name = generate_unique_name(&existing, &mut seeded_rng(seed));
// For a 2-word name, `choose_multiple` guarantees distinct indices,
// so the two words will always be different. Verify by checking that
// the name is not a word repeated (e.g. "mesa-mesa").
let word_set: HashSet<&str> = WORDS.iter().copied().collect();
for word in &word_set {
let repeated = format!("{word}-{word}");
assert_ne!(name, repeated, "generated name should never repeat a word");
}
}
}
#[test]
fn avoids_existing_branches() {
let mut existing = HashSet::new();
let mut rng = seeded_rng(7);
// Generate a name, add it to existing, then generate again —
// the second name must differ.
let first = generate_unique_name(&existing, &mut rng);
existing.insert(first.as_str());
let mut rng2 = seeded_rng(7);
let second = generate_unique_name(&existing, &mut rng2);
assert_ne!(first, second, "second name should avoid the first");
assert!(
!existing.contains(second.as_str()),
"second name should not be in existing set"
);
}
#[test]
fn escalates_to_three_words_when_two_word_space_exhausted() {
// Fill existing with all possible 2-word combos (198 * 197 = 39006).
// This is a large set but the test verifies the escalation logic.
let word_set: Vec<&str> = WORDS.to_vec();
let mut existing = HashSet::new();
for (i, a) in word_set.iter().enumerate() {
for (j, b) in word_set.iter().enumerate() {
if i != j {
existing.insert(format!("{a}-{b}"));
}
}
}
let existing_refs: HashSet<&str> = existing.iter().map(|s| s.as_str()).collect();
let name = generate_unique_name(&existing_refs, &mut seeded_rng(99));
// The name should have 3 words (3+ hyphens when words themselves may
// contain hyphens, so count words by checking the word list).
let mut remaining = name.as_str();
let mut word_count = 0;
while !remaining.is_empty() {
// Find the longest matching word at the start of `remaining`.
let matched = word_set
.iter()
.filter(|w| remaining.starts_with(**w))
.max_by_key(|w| w.len());
match matched {
Some(w) => {
word_count += 1;
remaining = &remaining[w.len()..];
if remaining.starts_with('-') {
remaining = &remaining[1..];
}
}
None => {
panic!("name contains a segment not in WORDS: remaining={remaining}, full={name}")
}
}
}
assert!(
word_count >= 3,
"expected >=3 words when 2-word space is exhausted, got {word_count} in {name}"
);
}
#[test]
fn all_words_are_valid_git_branch_components() {
for word in WORDS {
assert!(!word.is_empty(), "word must not be empty");
assert!(
!word.starts_with('-'),
"word must not start with hyphen: {word}"
);
assert!(
!word.ends_with('-'),
"word must not end with hyphen: {word}"
);
assert!(!word.contains(".."), "word must not contain '..': {word}");
assert!(!word.contains(' '), "word must not contain spaces: {word}");
assert!(
!word.contains(|c: char| c.is_ascii_control()),
"word must not contain control chars: {word}"
);
assert!(
word.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
"word must be lowercase ascii + hyphens: {word}"
);
}
}
#[test]
fn word_list_has_no_duplicates() {
let mut seen = HashSet::new();
for word in WORDS {
assert!(seen.insert(word), "duplicate word in WORDS: {word}");
}
}