use std::path::Path;
pub(crate) fn to_file_uri(path: &Path) -> String {
let raw = path.to_string_lossy();
let stripped = strip_verbatim_prefix(&raw);
let forward = stripped.replace('\\', "/");
let encoded = percent_encode(&forward);
let rooted = if is_drive_prefixed(&encoded) {
format!("/{encoded}")
} else {
encoded
};
format!("file://{rooted}")
}
fn strip_verbatim_prefix(s: &str) -> String {
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
format!(r"\\{rest}")
} else if let Some(rest) = s.strip_prefix(r"\\?\") {
rest.to_string()
} else {
s.to_string()
}
}
fn is_drive_prefixed(s: &str) -> bool {
let bytes = s.as_bytes();
bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'
}
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'%' => out.push_str("%25"),
' ' => out.push_str("%20"),
'#' => out.push_str("%23"),
'?' => out.push_str("%3F"),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
out.push(h * 16 + l);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn operator_decode(location: &str) -> String {
let rest = location.strip_prefix("file://").expect("file:// prefix");
let decoded = percent_decode(rest);
if is_windows_drive_match(&decoded) {
decoded.trim_start_matches('/').to_string()
} else {
decoded
}
}
fn is_windows_drive_match(s: &str) -> bool {
let s = s.strip_prefix('/').unwrap_or(s);
let b = s.as_bytes();
b.len() >= 3
&& b[0].is_ascii_alphabetic()
&& b[1] == b':'
&& (b[2] == b'/' || b[2] == b'\\')
}
fn forward_norm(p: &str) -> String {
strip_verbatim_prefix(p).replace('\\', "/")
}
#[test]
fn windows_drive_path_uses_triple_slash_forward_form() {
let uri = to_file_uri(Path::new(r"G:\git\KumihoIO\Revka\CLAUDE.md"));
assert_eq!(uri, "file:///G:/git/KumihoIO/Revka/CLAUDE.md");
}
#[test]
fn windows_verbatim_prefix_is_stripped() {
let uri = to_file_uri(Path::new(r"\\?\C:\Users\neo\.codex\AGENTS.md"));
assert_eq!(uri, "file:///C:/Users/neo/.codex/AGENTS.md");
}
#[test]
fn posix_absolute_path_uses_triple_slash() {
let uri = to_file_uri(Path::new("/Users/neo/.revka/x.md"));
assert_eq!(uri, "file:///Users/neo/.revka/x.md");
}
#[test]
fn spaces_and_reserved_chars_are_escaped() {
let uri = to_file_uri(Path::new(r"G:\my repo\a#b?c.md"));
assert_eq!(uri, "file:///G:/my%20repo/a%23b%3Fc.md");
}
#[test]
fn operator_decodes_windows_uri_back_to_source_path() {
let p = r"G:\git\KumihoIO\Revka\CLAUDE.md";
let uri = to_file_uri(Path::new(p));
assert_eq!(operator_decode(&uri), forward_norm(p));
assert_eq!(operator_decode(&uri), "G:/git/KumihoIO/Revka/CLAUDE.md");
}
#[test]
fn operator_decodes_posix_uri_back_to_source_path() {
let p = "/Users/neo/project/.claude/skills/foo/SKILL.md";
let uri = to_file_uri(Path::new(p));
assert_eq!(operator_decode(&uri), forward_norm(p));
}
#[test]
fn operator_decodes_escaped_chars_back_to_original() {
let p = r"G:\my repo\a#b.md";
let uri = to_file_uri(Path::new(p));
assert_eq!(operator_decode(&uri), "G:/my repo/a#b.md");
}
#[test]
fn from_file_uri_round_trips() {
for p in [
r"G:\git\foo\SKILL.md",
"/Users/neo/foo/SKILL.md",
r"G:\my repo\a#b.md",
] {
let uri = to_file_uri(Path::new(p));
let back = from_file_uri(&uri).expect("round trip");
assert_eq!(back.to_string_lossy().replace('\\', "/"), forward_norm(p));
}
}
fn from_file_uri(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://")?;
let decoded = percent_decode(rest);
let path = if is_windows_drive_match(&decoded) {
decoded.trim_start_matches('/').to_string()
} else {
decoded
};
Some(PathBuf::from(path))
}
}