From 9da3f2db4777acc5a2bb0300801844e8c4cd2960 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 23 Jan 2026 15:10:40 +0100 Subject: [PATCH] util: Implement host independent Url to PathBuf conversion (#47474) We might interface with the LSP using URL heres across remotes that have differing path styles which then breaks every now and then when we have windows to unix connections. This should helps us fix these occurences more correctly Release Notes: - N/A *or* Added/Fixed/Improved ... --- Cargo.lock | 2 + Cargo.toml | 1 + crates/acp_thread/src/acp_thread.rs | 6 + crates/agent_servers/src/acp.rs | 1 + crates/editor/src/clangd_ext.rs | 39 +- crates/project/src/terminals.rs | 10 +- crates/terminal/src/terminal.rs | 19 +- crates/terminal/src/terminal_hyperlinks.rs | 12 +- .../src/terminal_path_like_target.rs | 1 + crates/util/Cargo.toml | 2 + crates/util/src/paths.rs | 356 ++++++++++++++++++ 11 files changed, 422 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 05183e5309253fe4b36b4f0a7877273281e32cf7..4f5c18e14c03a97619ea1c59c9880eeae45cbcbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18241,6 +18241,7 @@ dependencies = [ "log", "mach2 0.5.0", "nix 0.29.0", + "percent-encoding", "pretty_assertions", "rand 0.9.2", "regex", @@ -18255,6 +18256,7 @@ dependencies = [ "tempfile", "tendril", "unicase", + "url", "util_macros", "walkdir", "which 6.0.3", diff --git a/Cargo.toml b/Cargo.toml index 7e40776f4a395afd120c900ca3da2230a3bef48e..d8f2b1cdf7d7327bd7aee30466c34f0a712c6ecc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -596,6 +596,7 @@ partial-json-fixer = "0.5.3" parse_int = "0.9" pciid-parser = "0.8.0" pathdiff = "0.2" +percent-encoding = "2.3.2" pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "d5b5bb0c4558a51d8cc76b514bc870fd1c042f16" } pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "d5b5bb0c4558a51d8cc76b514bc870fd1c042f16" } pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "d5b5bb0c4558a51d8cc76b514bc870fd1c042f16" } diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index bda1069d13225c038897f22a3748270a6b1aa0ea..ff9fe6ddd02dd044cf5a9dbc389f46ecfcbc1dd9 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -2631,6 +2631,7 @@ mod tests { None, 0, cx.background_executor(), + PathStyle::local(), ) .unwrap(); builder.subscribe(cx) @@ -2705,6 +2706,7 @@ mod tests { None, 0, cx.background_executor(), + PathStyle::local(), ) .unwrap(); builder.subscribe(cx) @@ -2791,6 +2793,7 @@ mod tests { Some(completion_tx), cx, vec![], + PathStyle::local(), ) }) .await @@ -4080,6 +4083,7 @@ mod tests { None, 0, cx.background_executor(), + PathStyle::local(), ) .unwrap(); builder.subscribe(cx) @@ -4126,6 +4130,7 @@ mod tests { None, 0, cx.background_executor(), + PathStyle::local(), ) .unwrap(); builder.subscribe(cx) @@ -4186,6 +4191,7 @@ mod tests { None, 0, cx.background_executor(), + PathStyle::local(), ) .unwrap(); builder.subscribe(cx) diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 646a3455852bb8e2b90658a9a09706dd01af26ba..8b65891ea646dec242a2fbbaa6f52ccf7c9bbfcc 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -1284,6 +1284,7 @@ impl acp::Client for ClientDelegate { None, 0, cx.background_executor(), + thread.project().read(cx).path_style(cx), )?; let lower = cx.new(|cx| builder.subscribe(cx)); thread.on_terminal_provider_event( diff --git a/crates/editor/src/clangd_ext.rs b/crates/editor/src/clangd_ext.rs index 892fdfb586013f69e38fdff20fe625dfe82f6c4c..a6805b8f40722d17c4c7ba01a07668d73d00c4d9 100644 --- a/crates/editor/src/clangd_ext.rs +++ b/crates/editor/src/clangd_ext.rs @@ -1,11 +1,10 @@ -use std::path::PathBuf; - use anyhow::Context as _; use gpui::{App, Context, Entity, Window}; use language::Language; use project::lsp_store::lsp_ext_command::SwitchSourceHeaderResult; use rpc::proto; -use util::paths::PathStyle; +use url::Url; +use util::paths::{PathStyle, UrlExt as _}; use workspace::{OpenOptions, OpenVisible}; use crate::lsp_ext::find_specific_language_server_in_selection; @@ -77,25 +76,22 @@ pub fn switch_source_header( if switch_source_header.0.is_empty() { return Ok(()); } - - let goto = switch_source_header - .0 - .strip_prefix("file://") - .with_context(|| { - format!( - "Parsing file url \"{}\" returned from switch source/header failed", - switch_source_header.0 - ) - })?; + let path_style = workspace.update(cx, |ws, cx| ws.path_style(cx)); + let path = Url::parse(&switch_source_header.0).with_context(|| { + format!( + "Parsing URL \"{}\" returned from switch source/header failed", + switch_source_header.0 + ) + })?; + let path = path.to_file_path_ext(path_style).map_err(|()| { + anyhow::anyhow!( + "URL conversion to file path failed for \"{}\"", + switch_source_header.0 + ) + })?; workspace .update_in(cx, |workspace, window, cx| { - let goto = if workspace.path_style(cx).is_windows() { - goto.strip_prefix('/').unwrap_or(goto) - } else { - goto - }; - let path = PathBuf::from(goto); workspace.open_abs_path( path, OpenOptions { @@ -107,7 +103,10 @@ pub fn switch_source_header( ) }) .with_context(|| { - format!("Switch source/header could not open \"{goto}\" in workspace") + format!( + "Switch source/header could not open \"{}\" in workspace", + switch_source_header.0 + ) })? .await .map(|_| ()) diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index f9f3672cf2f53516725108b761fe3c09b0064fee..7927a8ab2c3bcd9280d4dc7f0af5580bee8a3ef7 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -91,8 +91,8 @@ impl Project { .unwrap_or_else(get_default_system_shell), None => settings.shell.program(), }; - let is_windows = self.path_style(cx).is_windows(); - let shell_kind = ShellKind::new(&shell, is_windows); + let path_style = self.path_style(cx); + let shell_kind = ShellKind::new(&shell, path_style.is_windows()); // Prepare a task for resolving the environment let env_task = @@ -248,6 +248,7 @@ impl Project { Some(completion_tx), cx, activation_script, + path_style, )) })?? .await?; @@ -356,7 +357,7 @@ impl Project { None => settings.shell.program(), }; - let is_windows = self.path_style(cx).is_windows(); + let path_style = self.path_style(cx); // Prepare a task for resolving the environment let env_task = @@ -364,7 +365,7 @@ impl Project { let lang_registry = self.languages.clone(); cx.spawn(async move |project, cx| { - let shell_kind = ShellKind::new(&shell, is_windows); + let shell_kind = ShellKind::new(&shell, path_style.is_windows()); let mut env = env_task.await.unwrap_or_default(); env.extend(settings.env); @@ -412,6 +413,7 @@ impl Project { None, cx, activation_script, + path_style, )) })?? .await?; diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 245d8e23f649b900f0fbad70ee6c3dcef4620ff3..5722e5cb2ccbe7649f031c65b7018f10797cbd92 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -50,7 +50,7 @@ use terminal_hyperlinks::RegexSearches; use terminal_settings::{AlternateScroll, CursorShape, TerminalSettings}; use theme::{ActiveTheme, Theme}; use urlencoding; -use util::truncate_and_trailoff; +use util::{paths::PathStyle, truncate_and_trailoff}; use std::{ borrow::Cow, @@ -347,6 +347,7 @@ impl TerminalBuilder { max_scroll_history_lines: Option, window_id: u64, background_executor: &BackgroundExecutor, + path_style: PathStyle, ) -> Result { // Create a display-only terminal (no actual PTY). let default_cursor_style = AlacCursorStyle::from(cursor_shape); @@ -411,6 +412,7 @@ impl TerminalBuilder { child_exited: None, event_loop_task: Task::ready(Ok(())), background_executor: background_executor.clone(), + path_style, }; Ok(TerminalBuilder { @@ -434,6 +436,7 @@ impl TerminalBuilder { completion_tx: Option>>, cx: &App, activation_script: Vec, + path_style: PathStyle, ) -> Task> { let version = release_channel::AppVersion::global(cx); let background_executor = cx.background_executor().clone(); @@ -640,6 +643,7 @@ impl TerminalBuilder { child_exited: None, event_loop_task: Task::ready(Ok(())), background_executor, + path_style, }; if !activation_script.is_empty() && no_task { @@ -863,6 +867,7 @@ pub struct Terminal { child_exited: Option, event_loop_task: Task>, background_executor: BackgroundExecutor, + path_style: PathStyle, } struct CopyTemplate { @@ -1181,6 +1186,7 @@ impl Terminal { term, point, &mut self.hyperlink_regex_searches, + self.path_style, ) { Some(hyperlink) => { self.process_hyperlink(hyperlink, *open, cx); @@ -1869,6 +1875,7 @@ impl Terminal { &term_lock, point, &mut self.hyperlink_regex_searches, + self.path_style, ); drop(term_lock); @@ -1960,6 +1967,7 @@ impl Terminal { &term_lock, point, &mut self.hyperlink_regex_searches, + self.path_style, ) } { if mouse_down_hyperlink == mouse_up_hyperlink { @@ -2283,6 +2291,7 @@ impl Terminal { None, cx, self.activation_script.clone(), + self.path_style, ) } } @@ -2553,6 +2562,7 @@ mod tests { Some(completion_tx), cx, vec![], + PathStyle::local(), ) }) .await @@ -2574,6 +2584,7 @@ mod tests { None, 0, cx.background_executor(), + PathStyle::local(), ) .unwrap() .subscribe(cx) @@ -2697,6 +2708,7 @@ mod tests { Some(completion_tx), cx, Vec::new(), + PathStyle::local(), ) }) .await @@ -2772,6 +2784,7 @@ mod tests { Some(completion_tx), cx, Vec::new(), + PathStyle::local(), ) }) .await @@ -2958,6 +2971,7 @@ mod tests { None, 0, cx.background_executor(), + PathStyle::local(), ) .unwrap() .subscribe(cx) @@ -3005,6 +3019,7 @@ mod tests { None, 0, cx.background_executor(), + PathStyle::local(), ) .unwrap() .subscribe(cx) @@ -3046,6 +3061,7 @@ mod tests { None, 0, cx.background_executor(), + PathStyle::local(), ) .unwrap() .subscribe(cx) @@ -3256,6 +3272,7 @@ mod tests { None, cx, vec![], + PathStyle::local(), ) }) .await diff --git a/crates/terminal/src/terminal_hyperlinks.rs b/crates/terminal/src/terminal_hyperlinks.rs index 4fe2baa2dc27f3a589efd9b7739262a6fec3fcb4..5250dfb1a99cdff11da7fa75665801609ff50299 100644 --- a/crates/terminal/src/terminal_hyperlinks.rs +++ b/crates/terminal/src/terminal_hyperlinks.rs @@ -16,6 +16,7 @@ use std::{ time::{Duration, Instant}, }; use url::Url; +use util::paths::{PathStyle, UrlExt}; const URL_REGEX: &str = r#"(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file://|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>"\s{-}\^⟨⟩`']+"#; const WIDE_CHAR_SPACERS: Flags = @@ -70,6 +71,7 @@ pub(super) fn find_from_grid_point( term: &Term, point: AlacPoint, regex_searches: &mut RegexSearches, + path_style: PathStyle, ) -> Option<(String, bool, Match)> { let grid = term.grid(); let link = grid.index(point).hyperlink(); @@ -135,7 +137,7 @@ pub(super) fn find_from_grid_point( // (e.g., file:///C:/path -> C:\path) if maybe_url_or_path.starts_with("file://") { if let Ok(url) = Url::parse(&maybe_url_or_path) { - if let Ok(path) = url.to_file_path() { + if let Ok(path) = url.to_file_path_ext(path_style) { return (path.to_string_lossy().into_owned(), false, word_match); } } @@ -1217,7 +1219,12 @@ mod tests { } TEST_REGEX_SEARCHES.with(|regex_searches| { - find_from_grid_point(&term, point, &mut regex_searches.borrow_mut()) + find_from_grid_point( + &term, + point, + &mut regex_searches.borrow_mut(), + PathStyle::local(), + ) }) } } @@ -1819,6 +1826,7 @@ mod tests { &term, expected_hyperlink.hovered_grid_point, &mut regex_searches.borrow_mut(), + PathStyle::local(), ) }); let check_hyperlink_match = diff --git a/crates/terminal_view/src/terminal_path_like_target.rs b/crates/terminal_view/src/terminal_path_like_target.rs index fb99457eb498d65820f942f98543dfcf5ab1e597..8adad4ebf91f4557465afb2e8659594f05f3b716 100644 --- a/crates/terminal_view/src/terminal_path_like_target.rs +++ b/crates/terminal_view/src/terminal_path_like_target.rs @@ -562,6 +562,7 @@ mod tests { None, 0, cx.background_executor(), + PathStyle::local(), ) .expect("Failed to create display-only terminal") .subscribe(cx) diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index f58bc32bb4fcc29d865d941f2fadc8d1b58a2467..55997b25344d69e090581d46008d9983bc895bca 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -42,6 +42,8 @@ smol.workspace = true take-until.workspace = true tempfile.workspace = true unicase.workspace = true +url.workspace = true +percent-encoding.workspace = true util_macros = { workspace = true, optional = true } walkdir.workspace = true which.workspace = true diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 36f2ea4e23024a90a8b179d5d5eeb21a88ccbca4..f010ff57c636f40de50a06baefd99c9ee43728f9 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -1407,6 +1407,153 @@ impl WslPath { } } +pub trait UrlExt { + /// A version of `url::Url::to_file_path` that does platform handling based on the provided `PathStyle` instead of the host platform. + /// + /// Prefer using this over `url::Url::to_file_path` when you need to handle paths in a cross-platform way as is the case for remoting interactions. + fn to_file_path_ext(&self, path_style: PathStyle) -> Result; +} + +impl UrlExt for url::Url { + // Copied from `url::Url::to_file_path`, but the `cfg` handling is replaced with runtime branching on `PathStyle` + fn to_file_path_ext(&self, source_path_style: PathStyle) -> Result { + if let Some(segments) = self.path_segments() { + let host = match self.host() { + None | Some(url::Host::Domain("localhost")) => None, + Some(_) if source_path_style.is_windows() && self.scheme() == "file" => { + self.host_str() + } + _ => return Err(()), + }; + + let str_len = self.as_str().len(); + let estimated_capacity = if source_path_style.is_windows() { + // remove scheme: - has possible \\ for hostname + str_len.saturating_sub(self.scheme().len() + 1) + } else { + // remove scheme:// + str_len.saturating_sub(self.scheme().len() + 3) + }; + return match source_path_style { + PathStyle::Posix => { + file_url_segments_to_pathbuf_posix(estimated_capacity, host, segments) + } + PathStyle::Windows => { + file_url_segments_to_pathbuf_windows(estimated_capacity, host, segments) + } + }; + } + + fn file_url_segments_to_pathbuf_posix( + estimated_capacity: usize, + host: Option<&str>, + segments: std::str::Split<'_, char>, + ) -> Result { + use percent_encoding::percent_decode; + + if host.is_some() { + return Err(()); + } + + let mut bytes = Vec::new(); + bytes.try_reserve(estimated_capacity).map_err(|_| ())?; + + for segment in segments { + bytes.push(b'/'); + bytes.extend(percent_decode(segment.as_bytes())); + } + + // A windows drive letter must end with a slash. + if bytes.len() > 2 + && bytes[bytes.len() - 2].is_ascii_alphabetic() + && matches!(bytes[bytes.len() - 1], b':' | b'|') + { + bytes.push(b'/'); + } + + let path = String::from_utf8(bytes).map_err(|_| ())?; + debug_assert!( + PathStyle::Posix.is_absolute(&path), + "to_file_path() failed to produce an absolute Path" + ); + + Ok(PathBuf::from(path)) + } + + fn file_url_segments_to_pathbuf_windows( + estimated_capacity: usize, + host: Option<&str>, + mut segments: std::str::Split<'_, char>, + ) -> Result { + use percent_encoding::percent_decode_str; + let mut string = String::new(); + string.try_reserve(estimated_capacity).map_err(|_| ())?; + if let Some(host) = host { + string.push_str(r"\\"); + string.push_str(host); + } else { + let first = segments.next().ok_or(())?; + + match first.len() { + 2 => { + if !first.starts_with(|c| char::is_ascii_alphabetic(&c)) + || first.as_bytes()[1] != b':' + { + return Err(()); + } + + string.push_str(first); + } + + 4 => { + if !first.starts_with(|c| char::is_ascii_alphabetic(&c)) { + return Err(()); + } + let bytes = first.as_bytes(); + if bytes[1] != b'%' + || bytes[2] != b'3' + || (bytes[3] != b'a' && bytes[3] != b'A') + { + return Err(()); + } + + string.push_str(&first[0..1]); + string.push(':'); + } + + _ => return Err(()), + } + }; + + for segment in segments { + string.push('\\'); + + // Currently non-unicode windows paths cannot be represented + match percent_decode_str(segment).decode_utf8() { + Ok(s) => string.push_str(&s), + Err(..) => return Err(()), + } + } + // ensure our estimated capacity was good + if cfg!(test) { + debug_assert!( + string.len() <= estimated_capacity, + "len: {}, capacity: {}", + string.len(), + estimated_capacity + ); + } + debug_assert!( + PathStyle::Windows.is_absolute(&string), + "to_file_path() failed to produce an absolute Path" + ); + let path = PathBuf::from(string); + Ok(path) + } + Err(()) + } +} + #[cfg(test)] mod tests { use crate::rel_path::rel_path; @@ -2702,4 +2849,213 @@ mod tests { let path = r"\\windows.localhost\Distro\foo"; assert_eq!(WslPath::from_path(&path), None); } + + #[test] + fn test_url_to_file_path_ext_posix_basic() { + use super::UrlExt; + + let url = url::Url::parse("file:///home/user/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Posix), + Ok(PathBuf::from("/home/user/file.txt")) + ); + + let url = url::Url::parse("file:///").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Posix), + Ok(PathBuf::from("/")) + ); + + let url = url::Url::parse("file:///a/b/c/d/e").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Posix), + Ok(PathBuf::from("/a/b/c/d/e")) + ); + } + + #[test] + fn test_url_to_file_path_ext_posix_percent_encoding() { + use super::UrlExt; + + let url = url::Url::parse("file:///home/user/file%20with%20spaces.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Posix), + Ok(PathBuf::from("/home/user/file with spaces.txt")) + ); + + let url = url::Url::parse("file:///path%2Fwith%2Fencoded%2Fslashes").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Posix), + Ok(PathBuf::from("/path/with/encoded/slashes")) + ); + + let url = url::Url::parse("file:///special%23chars%3F.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Posix), + Ok(PathBuf::from("/special#chars?.txt")) + ); + } + + #[test] + fn test_url_to_file_path_ext_posix_localhost() { + use super::UrlExt; + + let url = url::Url::parse("file://localhost/home/user/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Posix), + Ok(PathBuf::from("/home/user/file.txt")) + ); + } + + #[test] + fn test_url_to_file_path_ext_posix_rejects_host() { + use super::UrlExt; + + let url = url::Url::parse("file://somehost/home/user/file.txt").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Posix), Err(())); + } + + #[test] + fn test_url_to_file_path_ext_posix_windows_drive_letter() { + use super::UrlExt; + + let url = url::Url::parse("file:///C:").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Posix), + Ok(PathBuf::from("/C:/")) + ); + + let url = url::Url::parse("file:///D|").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Posix), + Ok(PathBuf::from("/D|/")) + ); + } + + #[test] + fn test_url_to_file_path_ext_windows_basic() { + use super::UrlExt; + + let url = url::Url::parse("file:///C:/Users/user/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("C:\\Users\\user\\file.txt")) + ); + + let url = url::Url::parse("file:///D:/folder/subfolder/file.rs").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("D:\\folder\\subfolder\\file.rs")) + ); + + let url = url::Url::parse("file:///C:/").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("C:\\")) + ); + } + + #[test] + fn test_url_to_file_path_ext_windows_encoded_drive_letter() { + use super::UrlExt; + + let url = url::Url::parse("file:///C%3A/Users/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("C:\\Users\\file.txt")) + ); + + let url = url::Url::parse("file:///c%3a/Users/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("c:\\Users\\file.txt")) + ); + + let url = url::Url::parse("file:///D%3A/folder/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("D:\\folder\\file.txt")) + ); + + let url = url::Url::parse("file:///d%3A/folder/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("d:\\folder\\file.txt")) + ); + } + + #[test] + fn test_url_to_file_path_ext_windows_unc_path() { + use super::UrlExt; + + let url = url::Url::parse("file://server/share/path/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("\\\\server\\share\\path\\file.txt")) + ); + + let url = url::Url::parse("file://server/share").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("\\\\server\\share")) + ); + } + + #[test] + fn test_url_to_file_path_ext_windows_percent_encoding() { + use super::UrlExt; + + let url = url::Url::parse("file:///C:/Users/user/file%20with%20spaces.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("C:\\Users\\user\\file with spaces.txt")) + ); + + let url = url::Url::parse("file:///C:/special%23chars%3F.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("C:\\special#chars?.txt")) + ); + } + + #[test] + fn test_url_to_file_path_ext_windows_invalid_drive() { + use super::UrlExt; + + let url = url::Url::parse("file:///1:/path/file.txt").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(())); + + let url = url::Url::parse("file:///CC:/path/file.txt").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(())); + + let url = url::Url::parse("file:///C/path/file.txt").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(())); + + let url = url::Url::parse("file:///invalid").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(())); + } + + #[test] + fn test_url_to_file_path_ext_non_file_scheme() { + use super::UrlExt; + + let url = url::Url::parse("http://example.com/path").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Posix), Err(())); + assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(())); + + let url = url::Url::parse("https://example.com/path").unwrap(); + assert_eq!(url.to_file_path_ext(PathStyle::Posix), Err(())); + assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(())); + } + + #[test] + fn test_url_to_file_path_ext_windows_localhost() { + use super::UrlExt; + + let url = url::Url::parse("file://localhost/C:/Users/file.txt").unwrap(); + assert_eq!( + url.to_file_path_ext(PathStyle::Windows), + Ok(PathBuf::from("C:\\Users\\file.txt")) + ); + } }