first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -18,8 +18,22 @@ impl ContentVersion {
|
||||
pub fn as_i32(&self) -> i32 {
|
||||
self.0 as i32
|
||||
}
|
||||
|
||||
/// Reconstructs a `ContentVersion` from a raw value received over the wire.
|
||||
///
|
||||
/// This bypasses the global atomic counter and should only be used at
|
||||
/// protocol deserialization boundaries (e.g. converting a `u64` from a
|
||||
/// proto message back into a `ContentVersion`).
|
||||
pub fn from_raw(val: usize) -> Self {
|
||||
ContentVersion(val)
|
||||
}
|
||||
|
||||
/// Returns the underlying value as a `u64` for wire serialization.
|
||||
pub fn as_u64(&self) -> u64 {
|
||||
self.0 as u64
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "content_version_test.rs"]
|
||||
#[path = "content_version_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_raw_roundtrip() {
|
||||
let version = ContentVersion::from_raw(42);
|
||||
assert_eq!(version.as_u64(), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_raw_preserves_equality() {
|
||||
let a = ContentVersion::from_raw(7);
|
||||
let b = ContentVersion::from_raw(7);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_as_u64_matches_as_i32() {
|
||||
let version = ContentVersion::from_raw(100);
|
||||
assert_eq!(version.as_u64(), 100);
|
||||
assert_eq!(version.as_i32(), 100);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::{
|
||||
io,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum FileSaveError {
|
||||
|
||||
@@ -4,18 +4,22 @@
|
||||
//! 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;
|
||||
|
||||
use content_inspector::{inspect, ContentType};
|
||||
use mime_guess::{self, mime};
|
||||
|
||||
/// 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"];
|
||||
|
||||
/// File extension for Jupyter notebook files.
|
||||
const JUPYTER_NOTEBOOK_EXTENSION: &str = "ipynb";
|
||||
|
||||
/// 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 {
|
||||
@@ -140,6 +144,13 @@ pub fn is_markdown_file(path: impl AsRef<Path>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Guess whether or not `path` is a Jupyter notebook file based on its `.ipynb` extension.
|
||||
pub fn is_jupyter_notebook_file(path: impl AsRef<Path>) -> bool {
|
||||
path.as_ref()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case(JUPYTER_NOTEBOOK_EXTENSION))
|
||||
}
|
||||
|
||||
/// Determines if a file is likely to be a text file based on its filename.
|
||||
///
|
||||
/// This function uses a hybrid approach:
|
||||
@@ -226,7 +237,7 @@ fn is_development_text_extension(extension: &str) -> bool {
|
||||
"svelte" | "astro" | "blade" | "twig" | "mustache" | "hbs" |
|
||||
"handlebars" | "ejs" | "pug" | "jade" | "erb" | "haml" |
|
||||
// Configuration and data formats
|
||||
"toml" | "yaml" | "yml" | "json" | "jsonc" | "json5" |
|
||||
"toml" | "yaml" | "yml" | "json" | "jsonc" | "json5" | "jq" |
|
||||
"xml" | "ini" | "cfg" | "conf" | "config" | "properties" |
|
||||
"env" | "dotenv" | "editorconfig" | "gitignore" | "gitattributes" |
|
||||
// Documentation
|
||||
@@ -287,122 +298,5 @@ fn is_extensionless_text_file(filename: &str) -> bool {
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
#[path = "file_type_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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_is_jupyter_notebook_file() {
|
||||
assert!(is_jupyter_notebook_file("analysis.ipynb"));
|
||||
assert!(is_jupyter_notebook_file("Analysis.IPYNB"));
|
||||
assert!(is_jupyter_notebook_file("/path/to/notebook.ipynb"));
|
||||
|
||||
assert!(!is_jupyter_notebook_file("notebook.json"));
|
||||
assert!(!is_jupyter_notebook_file("README.md"));
|
||||
assert!(!is_jupyter_notebook_file("ipynb"));
|
||||
assert!(!is_jupyter_notebook_file("notebook"));
|
||||
}
|
||||
|
||||
#[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,71 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
/// Runs a git command and returns the output as a string.
|
||||
/// Thin wrapper over [`run_git_command_with_env`] with no `PATH` override.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub async fn run_git_command(repo_path: &Path, args: &[&str]) -> Result<String> {
|
||||
run_git_command_with_env(repo_path, args, None).await
|
||||
}
|
||||
|
||||
/// Like [`run_git_command`] but sets `PATH` on the child when `path_env` is
|
||||
/// `Some`. Used by callers whose hooks need user-installed binaries (e.g.
|
||||
/// the LFS `pre-push` hook → `git-lfs`). See `specs/APP-4188/TECH.md`.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub async fn run_git_command_with_env(
|
||||
repo_path: &Path,
|
||||
args: &[&str],
|
||||
path_env: Option<&str>,
|
||||
) -> Result<String> {
|
||||
use command::r#async::Command;
|
||||
use command::Stdio;
|
||||
|
||||
log::debug!(
|
||||
"[GIT OPERATION] git.rs run_git_command git {}",
|
||||
args.join(" ")
|
||||
);
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-c")
|
||||
.arg("diff.autoRefreshIndex=false")
|
||||
.args(args)
|
||||
.current_dir(repo_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.kill_on_drop(true);
|
||||
if let Some(path_env) = path_env {
|
||||
cmd.env("PATH", path_env);
|
||||
}
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to execute git command: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Handle git diff specific behavior:
|
||||
// - Exit code 0: no differences
|
||||
// - Exit code 1: differences found (this is normal for diff commands)
|
||||
// - Exit code > 1: actual error
|
||||
if output.status.success() || (output.status.code() == Some(1) && !stdout.is_empty()) {
|
||||
Ok(stdout)
|
||||
} else {
|
||||
Err(anyhow!("Git command failed: {}, {}", stderr, stdout))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub async fn run_git_command(_repo_path: &Path, _args: &[&str]) -> Result<String> {
|
||||
Err(anyhow!("Not supported on wasm"))
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub async fn run_git_command_with_env(
|
||||
_repo_path: &Path,
|
||||
_args: &[&str],
|
||||
_path_env: Option<&str>,
|
||||
) -> Result<String> {
|
||||
Err(anyhow!("Not supported on wasm"))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Opaque identifier for a remote host.
|
||||
///
|
||||
/// Returned by the server in `InitializeResponse`. Used by
|
||||
/// `RemoteServerManager` and downstream features to deduplicate
|
||||
/// host-scoped models (e.g. `RepoMetadataModel`) across sessions.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct HostId(String);
|
||||
|
||||
impl HostId {
|
||||
pub fn new(id: String) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for HostId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,14 @@ pub mod assets;
|
||||
pub mod content_version;
|
||||
pub mod file;
|
||||
pub mod file_type;
|
||||
pub mod git;
|
||||
pub mod host_id;
|
||||
pub mod local_or_remote_path;
|
||||
pub mod on_cancel;
|
||||
pub mod path;
|
||||
pub mod remote_path;
|
||||
pub mod standardized_path;
|
||||
pub mod sync;
|
||||
pub mod user_input;
|
||||
pub mod worktree_names;
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::remote_path::RemotePath;
|
||||
use crate::standardized_path::StandardizedPath;
|
||||
|
||||
/// Uniquely identifies where a file lives — either on the local filesystem
|
||||
/// or on a remote host. Used across both the buffer model and the
|
||||
/// editor/view layers as the canonical file-identity type.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub enum LocalOrRemotePath {
|
||||
/// File on the local filesystem.
|
||||
Local(PathBuf),
|
||||
/// File on a remote host, identified by host + path.
|
||||
Remote(RemotePath),
|
||||
}
|
||||
|
||||
impl LocalOrRemotePath {
|
||||
/// Returns `true` if this is a `Local` location.
|
||||
pub fn is_local(&self) -> bool {
|
||||
matches!(self, LocalOrRemotePath::Local(_))
|
||||
}
|
||||
|
||||
/// Returns `true` if this is a `Remote` location.
|
||||
pub fn is_remote(&self) -> bool {
|
||||
matches!(self, LocalOrRemotePath::Remote(_))
|
||||
}
|
||||
|
||||
/// Returns the standardized path component of the location, regardless of where it lives.
|
||||
pub fn path_component(&self) -> StandardizedPath {
|
||||
match self {
|
||||
LocalOrRemotePath::Local(path) => StandardizedPath::from_local_absolute_unchecked(path),
|
||||
LocalOrRemotePath::Remote(remote) => remote.path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the file name component for display (e.g. tab titles).
|
||||
pub fn display_name(&self) -> &str {
|
||||
match self {
|
||||
LocalOrRemotePath::Local(path) => path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or_default(),
|
||||
LocalOrRemotePath::Remote(remote) => remote.path.file_name().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a displayable path string.
|
||||
pub fn display_path(&self) -> String {
|
||||
match self {
|
||||
LocalOrRemotePath::Local(path) => path.to_string_lossy().to_string(),
|
||||
LocalOrRemotePath::Remote(remote) => {
|
||||
format!("{}", remote.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns this location's parent, preserving remote host identity.
|
||||
pub fn parent(&self) -> Option<LocalOrRemotePath> {
|
||||
match self {
|
||||
LocalOrRemotePath::Local(path) => path
|
||||
.parent()
|
||||
.map(|parent| LocalOrRemotePath::Local(parent.to_path_buf())),
|
||||
LocalOrRemotePath::Remote(remote) => remote.path.parent().map(|parent| {
|
||||
LocalOrRemotePath::Remote(RemotePath::new(remote.host_id.clone(), parent))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the file name component, regardless of where the path lives.
|
||||
pub fn file_name(&self) -> Option<&str> {
|
||||
match self {
|
||||
LocalOrRemotePath::Local(path) => path.file_name().and_then(|name| name.to_str()),
|
||||
LocalOrRemotePath::Remote(remote) => remote.path.file_name(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether this location starts with `base`.
|
||||
///
|
||||
/// Remote locations only compare as ancestors when they are on the same
|
||||
/// host. This prevents `/repo` on one host from matching `/repo` on another.
|
||||
pub fn starts_with(&self, base: &LocalOrRemotePath) -> bool {
|
||||
match (self, base) {
|
||||
(LocalOrRemotePath::Local(path), LocalOrRemotePath::Local(base)) => {
|
||||
path.starts_with(base)
|
||||
}
|
||||
(LocalOrRemotePath::Remote(path), LocalOrRemotePath::Remote(base)) => {
|
||||
path.host_id == base.host_id && path.path.starts_with(&base.path)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Returns the local path if this is a `Local` location, `None` for `Remote`.
|
||||
/// Callers that only work with local files (LSP, save-to-disk, reveal-in-finder)
|
||||
/// should use this to gate their behavior.
|
||||
pub fn to_local_path(&self) -> Option<&Path> {
|
||||
match self {
|
||||
LocalOrRemotePath::Local(path) => Some(path.as_path()),
|
||||
LocalOrRemotePath::Remote(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the remote path if this is a `Remote` location, `None` for `Local`.
|
||||
/// Callers that only work with remote files should use this to gate their
|
||||
/// behavior.
|
||||
pub fn as_remote(&self) -> Option<&RemotePath> {
|
||||
match self {
|
||||
LocalOrRemotePath::Local(_) => None,
|
||||
LocalOrRemotePath::Remote(remote) => Some(remote),
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins a (typically repo-relative) segment onto this location, preserving
|
||||
/// the host.
|
||||
///
|
||||
/// Accepts a `&str` rather than a `&Path` so that no caller is forced to
|
||||
/// construct a local-filesystem path type when working with paths that
|
||||
/// may originate from a remote host.
|
||||
///
|
||||
/// For `Local`, this delegates to `PathBuf::join` and yields a new local
|
||||
/// path. For `Remote`, the host id is carried through and only the
|
||||
/// path component is extended.
|
||||
///
|
||||
/// Note: if `segment` is itself absolute, the standard `Path::join`
|
||||
/// replacement semantics apply (the joined result is `segment`), so
|
||||
/// callers that already hold an absolute path from a wire decode will
|
||||
/// get back the absolute path unchanged — modulo host preservation on
|
||||
/// the remote side.
|
||||
pub fn join(&self, segment: &str) -> LocalOrRemotePath {
|
||||
match self {
|
||||
LocalOrRemotePath::Local(path) => LocalOrRemotePath::Local(path.join(segment)),
|
||||
LocalOrRemotePath::Remote(remote) => {
|
||||
let joined = remote.path.join(segment);
|
||||
LocalOrRemotePath::Remote(RemotePath::new(remote.host_id.clone(), joined))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If `file` shares this location's host and starts with this location's
|
||||
/// path, returns the relative remainder as a `String`. Returns `None`
|
||||
/// when the hosts differ or when `file` is not under this location.
|
||||
///
|
||||
/// Returns a `String` (rather than `PathBuf`) so that callers do not
|
||||
/// implicitly assume the relative remainder lives on the local
|
||||
/// filesystem — remote paths may use a different encoding than the host
|
||||
/// the client is running on.
|
||||
///
|
||||
/// Use this when you want to compute a repo-relative path from an
|
||||
/// absolute file path without silently dropping the host id (as
|
||||
/// `path_component().strip_prefix(...)` would).
|
||||
pub fn strip_repo_prefix(&self, file: &LocalOrRemotePath) -> Option<String> {
|
||||
match (self, file) {
|
||||
(LocalOrRemotePath::Local(repo), LocalOrRemotePath::Local(f)) => f
|
||||
.strip_prefix(repo)
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().into_owned()),
|
||||
(LocalOrRemotePath::Remote(repo), LocalOrRemotePath::Remote(f))
|
||||
if repo.host_id == f.host_id =>
|
||||
{
|
||||
f.path.strip_prefix(&repo.path).map(str::to_owned)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PathBuf> for LocalOrRemotePath {
|
||||
fn from(path: PathBuf) -> Self {
|
||||
LocalOrRemotePath::Local(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RemotePath> for LocalOrRemotePath {
|
||||
fn from(remote: RemotePath) -> Self {
|
||||
LocalOrRemotePath::Remote(remote)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<LocalOrRemotePath> for PathBuf {
|
||||
type Error = RemotePath;
|
||||
|
||||
fn try_from(location: LocalOrRemotePath) -> Result<Self, Self::Error> {
|
||||
match location {
|
||||
LocalOrRemotePath::Local(path) => Ok(path),
|
||||
LocalOrRemotePath::Remote(remote) => Err(remote),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&LocalOrRemotePath> for PathBuf {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(location: &LocalOrRemotePath) -> Result<Self, Self::Error> {
|
||||
match location {
|
||||
LocalOrRemotePath::Local(path) => Ok(path.clone()),
|
||||
LocalOrRemotePath::Remote(_) => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "local_or_remote_path_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,203 @@
|
||||
use crate::host_id::HostId;
|
||||
use crate::local_or_remote_path::LocalOrRemotePath;
|
||||
use crate::remote_path::RemotePath;
|
||||
use crate::standardized_path::StandardizedPath;
|
||||
|
||||
fn test_host_id() -> HostId {
|
||||
HostId::new("test-host".to_string())
|
||||
}
|
||||
|
||||
fn local_repo_path() -> std::path::PathBuf {
|
||||
#[cfg(unix)]
|
||||
let path = "/repo";
|
||||
#[cfg(windows)]
|
||||
let path = r"C:\repo";
|
||||
|
||||
path.into()
|
||||
}
|
||||
|
||||
fn local_file_path() -> std::path::PathBuf {
|
||||
local_repo_path().join("file.txt")
|
||||
}
|
||||
|
||||
fn local_absolute_file_path() -> std::path::PathBuf {
|
||||
#[cfg(unix)]
|
||||
let path = "/server/repo/src/foo.rs";
|
||||
#[cfg(windows)]
|
||||
let path = r"C:\server\repo\src\foo.rs";
|
||||
|
||||
path.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_or_remote_path_helpers_return_local_path_components() {
|
||||
let local_file = local_file_path();
|
||||
let path = LocalOrRemotePath::Local(local_file.clone());
|
||||
|
||||
assert_eq!(path.display_name(), "file.txt");
|
||||
assert_eq!(
|
||||
path.path_component(),
|
||||
StandardizedPath::try_from_local(&local_file).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
path.display_path(),
|
||||
local_file.to_string_lossy().into_owned()
|
||||
);
|
||||
assert_eq!(path.to_local_path(), Some(local_file.as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_or_remote_path_helpers_return_remote_path_components() {
|
||||
let path = LocalOrRemotePath::Remote(RemotePath::new(
|
||||
test_host_id(),
|
||||
StandardizedPath::try_new("/tmp/repo/file.txt").unwrap(),
|
||||
));
|
||||
|
||||
assert_eq!(path.display_name(), "file.txt");
|
||||
assert_eq!(
|
||||
path.path_component(),
|
||||
StandardizedPath::try_new("/tmp/repo/file.txt").unwrap()
|
||||
);
|
||||
assert_eq!(path.display_path(), "/tmp/repo/file.txt");
|
||||
assert_eq!(path.to_local_path(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_or_remote_path_is_local_and_is_remote_classify_variants() {
|
||||
let local = LocalOrRemotePath::Local(local_repo_path());
|
||||
let remote = LocalOrRemotePath::Remote(RemotePath::new(
|
||||
test_host_id(),
|
||||
StandardizedPath::try_new("/tmp/repo").unwrap(),
|
||||
));
|
||||
|
||||
assert!(local.is_local());
|
||||
assert!(!local.is_remote());
|
||||
assert!(!remote.is_local());
|
||||
assert!(remote.is_remote());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_or_remote_path_join_preserves_host_for_remote() {
|
||||
let local_repo = local_repo_path();
|
||||
let local = LocalOrRemotePath::Local(local_repo.clone());
|
||||
let remote = LocalOrRemotePath::Remote(RemotePath::new(
|
||||
test_host_id(),
|
||||
StandardizedPath::try_new("/repo").unwrap(),
|
||||
));
|
||||
|
||||
let local_joined = local.join("src/foo.rs");
|
||||
let expected_local_joined = local_repo.join("src/foo.rs");
|
||||
assert_eq!(
|
||||
local_joined.path_component(),
|
||||
StandardizedPath::try_from_local(&expected_local_joined).unwrap()
|
||||
);
|
||||
assert!(local_joined.is_local());
|
||||
|
||||
let remote_joined = remote.join("src/foo.rs");
|
||||
assert_eq!(
|
||||
remote_joined.path_component(),
|
||||
StandardizedPath::try_new("/repo/src/foo.rs").unwrap()
|
||||
);
|
||||
assert!(remote_joined.is_remote());
|
||||
if let LocalOrRemotePath::Remote(remote) = remote_joined {
|
||||
assert_eq!(remote.host_id, test_host_id());
|
||||
} else {
|
||||
panic!("expected remote variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_or_remote_path_join_with_absolute_replaces_prefix() {
|
||||
let local = LocalOrRemotePath::Local(local_repo_path());
|
||||
let remote = LocalOrRemotePath::Remote(RemotePath::new(
|
||||
test_host_id(),
|
||||
StandardizedPath::try_new("/some/repo").unwrap(),
|
||||
));
|
||||
let local_abs = local_absolute_file_path();
|
||||
let local_abs_str = local_abs.to_string_lossy().into_owned();
|
||||
let remote_abs = "/server/repo/src/foo.rs";
|
||||
|
||||
// Path::join replacement semantics on absolute argument.
|
||||
let local_joined = local.join(&local_abs_str);
|
||||
assert_eq!(
|
||||
local_joined.path_component(),
|
||||
StandardizedPath::try_from_local(&local_abs).unwrap()
|
||||
);
|
||||
|
||||
let remote_joined = remote.join(remote_abs);
|
||||
assert_eq!(
|
||||
remote_joined.path_component(),
|
||||
StandardizedPath::try_new("/server/repo/src/foo.rs").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_or_remote_path_strip_repo_prefix_local_local() {
|
||||
let repo = LocalOrRemotePath::Local(local_repo_path());
|
||||
let inside = LocalOrRemotePath::Local(local_repo_path().join("src").join("foo.rs"));
|
||||
let outside = LocalOrRemotePath::Local(local_absolute_file_path());
|
||||
let expected_relative = std::path::Path::new("src")
|
||||
.join("foo.rs")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
assert_eq!(repo.strip_repo_prefix(&inside), Some(expected_relative));
|
||||
assert_eq!(repo.strip_repo_prefix(&outside), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_or_remote_path_strip_repo_prefix_requires_same_host() {
|
||||
let host_a = HostId::new("host-a".to_string());
|
||||
let host_b = HostId::new("host-b".to_string());
|
||||
let repo_a = LocalOrRemotePath::Remote(RemotePath::new(
|
||||
host_a.clone(),
|
||||
StandardizedPath::try_new("/repo").unwrap(),
|
||||
));
|
||||
let file_a = LocalOrRemotePath::Remote(RemotePath::new(
|
||||
host_a,
|
||||
StandardizedPath::try_new("/repo/src/foo.rs").unwrap(),
|
||||
));
|
||||
let file_b = LocalOrRemotePath::Remote(RemotePath::new(
|
||||
host_b,
|
||||
StandardizedPath::try_new("/repo/src/foo.rs").unwrap(),
|
||||
));
|
||||
let file_local = LocalOrRemotePath::Local(local_repo_path().join("src/foo.rs"));
|
||||
|
||||
assert_eq!(
|
||||
repo_a.strip_repo_prefix(&file_a),
|
||||
Some("src/foo.rs".to_string()),
|
||||
);
|
||||
assert_eq!(
|
||||
repo_a.strip_repo_prefix(&file_b),
|
||||
None,
|
||||
"cross-host strip should be rejected"
|
||||
);
|
||||
assert_eq!(
|
||||
repo_a.strip_repo_prefix(&file_local),
|
||||
None,
|
||||
"local-vs-remote strip should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_or_remote_path_strip_repo_prefix_rejects_sibling_dirs() {
|
||||
// A remote file under a *sibling* directory whose name merely shares a
|
||||
// string prefix with the repo (`/repository` vs `/repo`) is not inside
|
||||
// the repo and must not be stripped. This must match the `Local` arm,
|
||||
// which already rejects it via component-aware `Path::strip_prefix`.
|
||||
let host = HostId::new("host".to_string());
|
||||
let repo = LocalOrRemotePath::Remote(RemotePath::new(
|
||||
host.clone(),
|
||||
StandardizedPath::try_new("/repo").unwrap(),
|
||||
));
|
||||
let sibling = LocalOrRemotePath::Remote(RemotePath::new(
|
||||
host,
|
||||
StandardizedPath::try_new("/repository/src/foo.rs").unwrap(),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
repo.strip_repo_prefix(&sibling),
|
||||
None,
|
||||
"sibling dir sharing a string prefix is not inside the repo"
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
use pin_project::{pin_project, pinned_drop};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use pin_project::{pin_project, pinned_drop};
|
||||
|
||||
/// Trait allowing you to attach a function to a [`Future`] that will be called if the future is
|
||||
/// cancelled.
|
||||
pub trait OnCancelFutureExt
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use crate::on_cancel::OnCancelFutureExt;
|
||||
use futures_util::future::{AbortHandle, Abortable, Aborted};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use futures_util::future::{AbortHandle, Abortable, Aborted};
|
||||
|
||||
use crate::on_cancel::OnCancelFutureExt;
|
||||
|
||||
#[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);
|
||||
galaxyui_core::r#async::block_on(future);
|
||||
|
||||
assert!(!callback_called.load(Ordering::Relaxed));
|
||||
}
|
||||
@@ -24,7 +26,7 @@ fn test_aborted_future_calls_callback() {
|
||||
|
||||
// Abort the future before it is ever polled.
|
||||
handle.abort();
|
||||
let future_result = galaxyui::r#async::block_on(future);
|
||||
let future_result = galaxyui_core::r#async::block_on(future);
|
||||
|
||||
// The future should be aborted and the callback should have been called.
|
||||
assert_eq!(future_result, Err(Aborted));
|
||||
|
||||
@@ -455,21 +455,26 @@ pub fn convert_wsl_to_windows_host_path(
|
||||
}
|
||||
let components = unix_path.components();
|
||||
let prefix = components.take(3).collect::<Vec<_>>();
|
||||
let windows_path = match prefix.as_slice() {
|
||||
let mut windows_path = TypedPathBuf::new(PathType::Windows);
|
||||
match prefix.as_slice() {
|
||||
// Check if the prefix is "/mnt/c/" or similar, which is how WSL refers to Windows drive
|
||||
// "C:\". Valid drive names are a..=z, which are bytes 97..=122.
|
||||
[TypedComponent::Unix(UnixComponent::RootDir), TypedComponent::Unix(UnixComponent::Normal(b"mnt")), TypedComponent::Unix(UnixComponent::Normal(bytes))]
|
||||
if bytes.len() == 1 && (97..=122).contains(&bytes[0]) =>
|
||||
{
|
||||
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 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),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let mut windows_path = TypedPathBuf::new(PathType::Windows);
|
||||
windows_path.push(format!(r"\\WSL$\{distro_name}"));
|
||||
for component in unix_path
|
||||
.with_windows_encoding()
|
||||
@@ -478,15 +483,8 @@ pub fn convert_wsl_to_windows_host_path(
|
||||
{
|
||||
windows_path.push(component.as_bytes());
|
||||
}
|
||||
windows_path
|
||||
PathBuf::try_from(windows_path).map_err(WSLPathConversionError::CouldNotConvertToPath)
|
||||
}
|
||||
};
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,7 +571,6 @@ pub fn to_relative_path(is_wsl: bool, absolute_path: &Path, cwd: &Path) -> Optio
|
||||
// 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()),
|
||||
@@ -847,5 +844,5 @@ pub fn group_roots_by_common_ancestor<P: RootPath>(roots: &[P]) -> RootGrouping<
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "path_test.rs"]
|
||||
#[path = "path_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -19,9 +19,10 @@ fn test_user_friendly_path_with_home() {
|
||||
|
||||
#[test]
|
||||
fn test_to_relative_path() {
|
||||
use super::to_relative_path;
|
||||
use std::path::Path;
|
||||
|
||||
use super::to_relative_path;
|
||||
|
||||
// Basic relative path conversion
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
@@ -204,7 +205,6 @@ fn test_to_relative_path() {
|
||||
|
||||
#[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")),
|
||||
@@ -704,9 +704,10 @@ fn test_canonicalize_git_bash_path() {
|
||||
// ── 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;
|
||||
|
||||
use crate::path::group_roots_by_common_ancestor;
|
||||
|
||||
fn pb(s: &str) -> PathBuf {
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,811 @@
|
||||
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 std::path::Path;
|
||||
|
||||
use super::to_relative_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() {
|
||||
|
||||
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 std::path::PathBuf;
|
||||
|
||||
use crate::path::group_roots_by_common_ancestor;
|
||||
|
||||
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,36 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::host_id::HostId;
|
||||
use crate::standardized_path::StandardizedPath;
|
||||
|
||||
/// Identifies a file on a remote host.
|
||||
///
|
||||
/// Pairs a [`HostId`] (to deduplicate across multiple SSH sessions to the
|
||||
/// same host) with the server-side [`StandardizedPath`]. This type is the
|
||||
/// canonical representation for remote file locations and is shared across
|
||||
/// buffer tracking, repository identification, and other host-scoped features.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct RemotePath {
|
||||
pub host_id: HostId,
|
||||
pub path: StandardizedPath,
|
||||
}
|
||||
|
||||
impl RemotePath {
|
||||
pub fn new(host_id: HostId, path: StandardizedPath) -> Self {
|
||||
Self { host_id, path }
|
||||
}
|
||||
|
||||
/// Returns true if the given host and path identify this remote path.
|
||||
pub fn matches(&self, host_id: &HostId, path: &StandardizedPath) -> bool {
|
||||
host_id == &self.host_id && path == &self.path
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a `navigate_to_directory` request to the remote server.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteNavigationResult {
|
||||
/// The canonicalized remote path returned by the server.
|
||||
pub remote_path: RemotePath,
|
||||
/// Whether the server detected a git repository at this path.
|
||||
pub is_git: bool,
|
||||
}
|
||||
@@ -5,9 +5,8 @@
|
||||
//! [`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 std::{fmt, io};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typed_path::{PathType, TypedPath, TypedPathBuf};
|
||||
@@ -152,16 +151,27 @@ impl StandardizedPath {
|
||||
}
|
||||
|
||||
/// Strip a prefix from this path, returning the relative remainder.
|
||||
///
|
||||
/// Matching is done at the component level (consistent with
|
||||
/// [`starts_with`](Self::starts_with)), so `base = /repo` does **not**
|
||||
/// match `self = /repository/foo.rs`; that returns `None` rather than a
|
||||
/// bogus mid-component 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.
|
||||
// Gate on the component-aware `starts_with` so we never strip a base
|
||||
// that is only a *string* prefix (e.g. `/repo` vs `/repository`). For
|
||||
// two normalized paths, a component prefix is also a byte prefix, so
|
||||
// slicing at `base` length lands exactly on a component boundary.
|
||||
if !self.starts_with(base) {
|
||||
return None;
|
||||
}
|
||||
let remainder = &self.as_str()[base.as_str().len()..];
|
||||
Some(
|
||||
// Remove the separator left between `base` and the remainder.
|
||||
remainder
|
||||
.strip_prefix('/')
|
||||
.or_else(|| remainder.strip_prefix('\\'))
|
||||
.unwrap_or(remainder)
|
||||
})
|
||||
.unwrap_or(remainder),
|
||||
)
|
||||
}
|
||||
|
||||
/// Join a relative segment onto this path.
|
||||
|
||||
@@ -102,6 +102,30 @@ fn strip_prefix() {
|
||||
assert_eq!(p.strip_prefix(&base), Some("src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_prefix_equal_path() {
|
||||
let p = StandardizedPath::try_new("/home/user/project").unwrap();
|
||||
let base = StandardizedPath::try_new("/home/user/project").unwrap();
|
||||
assert_eq!(p.strip_prefix(&base), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_prefix_matches_only_whole_components() {
|
||||
// `/repository` is a string-prefix sibling of `/repo`, but it is NOT a
|
||||
// path-component descendant of it. `strip_prefix` must behave like
|
||||
// `starts_with` and refuse to strip, returning `None` rather than a
|
||||
// bogus mid-component remainder like `Some("sitory/foo.rs")`.
|
||||
let base = StandardizedPath::try_new("/repo").unwrap();
|
||||
let sibling = StandardizedPath::try_new("/repository/foo.rs").unwrap();
|
||||
assert!(!sibling.starts_with(&base));
|
||||
assert_eq!(sibling.strip_prefix(&base), None);
|
||||
|
||||
let nested_base = StandardizedPath::try_new("/home/user").unwrap();
|
||||
let nested_sibling = StandardizedPath::try_new("/home/username/x").unwrap();
|
||||
assert!(!nested_sibling.starts_with(&nested_base));
|
||||
assert_eq!(nested_sibling.strip_prefix(&nested_base), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join() {
|
||||
let p = StandardizedPath::try_new("/home/user").unwrap();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Synchronization utilities.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use event_listener::Event;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sync_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// A set-once asynchronous condition variable.
|
||||
///
|
||||
/// Generally, a [condition variable](http://www.cs.cornell.edu/courses/cs3110/2012fa/recitations/rec16.html)
|
||||
/// lets tasks wait until some condition becomes true (for example, we might want to wait for the
|
||||
/// user to have logged in, or for the initial load of Warp Drive objects to have finished). When
|
||||
/// the condition becomes true, one or all of the waiting tasks can wake up and do their work.
|
||||
///
|
||||
/// This [`Condition`] implementation models the simpler case where a condition becomes true and
|
||||
/// is then *always* true (unless reset explicitly). This allows waiting for something to happen at
|
||||
/// least once. If a task starts waiting before the condition is met, it will block, but if the
|
||||
/// condition is already true, it continues immediately.
|
||||
///
|
||||
/// This is useful when regular UI framework events are not suitable because they do not tell us if
|
||||
/// the event had *already* happened - a task that subscribed too late would block forever.
|
||||
///
|
||||
/// Also see [`std::sync::Condvar`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Condition {
|
||||
// This is more or less the reference example for event-listener:
|
||||
// https://github.com/smol-rs/event-listener
|
||||
flag: Arc<AtomicBool>,
|
||||
event: Arc<Event>,
|
||||
}
|
||||
|
||||
impl Condition {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
flag: Arc::new(AtomicBool::new(false)),
|
||||
event: Arc::new(Event::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the condition as true.
|
||||
pub fn set(&self) {
|
||||
self.flag.store(true, Ordering::SeqCst);
|
||||
self.event.notify(usize::MAX);
|
||||
}
|
||||
|
||||
/// Reset the condition to false so that future [`wait`](Self::wait) calls
|
||||
/// will block until [`set`](Self::set) is called again.
|
||||
pub fn reset(&self) {
|
||||
self.flag.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Returns `true` if the condition has already been set.
|
||||
pub fn is_set(&self) -> bool {
|
||||
self.flag.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Asynchronously wait for the condition to be true.
|
||||
pub fn wait(&self) -> impl Future<Output = ()> {
|
||||
let flag = self.flag.clone();
|
||||
let event = self.event.clone();
|
||||
async move {
|
||||
// Loop in case of spurious wakeups.
|
||||
loop {
|
||||
// Check if the condition has already been set.
|
||||
if flag.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
let listener = event.listen();
|
||||
|
||||
// Check the flag again after creating the listener, in case it was set while we
|
||||
// started listening.
|
||||
if flag.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
listener.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Condition {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use futures_lite::future;
|
||||
|
||||
use super::Condition;
|
||||
|
||||
#[test]
|
||||
fn test_condition_multiple_waiters() {
|
||||
future::block_on(async {
|
||||
let condition = Condition::new();
|
||||
|
||||
let mut listener1 = Box::pin(condition.wait());
|
||||
let mut listener2 = Box::pin(condition.wait());
|
||||
|
||||
// Neither listener should be ready.
|
||||
assert!(future::poll_once(&mut listener1).await.is_none());
|
||||
assert!(future::poll_once(&mut listener2).await.is_none());
|
||||
|
||||
condition.set();
|
||||
|
||||
// Now, both should complete.
|
||||
assert!(future::poll_once(listener1).await.is_some());
|
||||
assert!(future::poll_once(listener2).await.is_some());
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_condition_after_set() {
|
||||
future::block_on(async {
|
||||
let condition = Condition::new();
|
||||
condition.set();
|
||||
|
||||
// After the condition is set, waiting should complete immediately.
|
||||
assert!(future::poll_once(condition.wait()).await.is_some());
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_condition_multiple_sets() {
|
||||
future::block_on(async {
|
||||
let condition = Condition::new();
|
||||
|
||||
// Test that multiple interleavings of `wait` and `set` all complete as expected.
|
||||
let first = condition.wait();
|
||||
condition.set();
|
||||
let second = condition.wait();
|
||||
condition.set();
|
||||
let third = condition.wait();
|
||||
|
||||
assert!(future::poll_once(first).await.is_some());
|
||||
assert!(future::poll_once(second).await.is_some());
|
||||
assert!(future::poll_once(third).await.is_some());
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
use std::{
|
||||
fmt::{self, Debug},
|
||||
ops::{Deref, DerefMut},
|
||||
};
|
||||
use std::fmt::{self, Debug};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
/// Wrapper type for values which may contain user input.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user