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")) + ); + } }