Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -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}")
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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")]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user