fn is_absolute_path(path: &str) -> bool {
matches!(path.as_bytes().first(), Some(b'/') | Some(b'\\'))
}
fn url_is_local_not_ssh(url: &str) -> bool {
let colon = url.find(':');
let slash = url.find('/');
match colon {
None => true,
Some(colon_pos) => slash.is_some_and(|slash_pos| slash_pos < colon_pos),
}
}
fn is_dir_sep(c: u8) -> bool {
c == b'/' || c == b'\\'
}
fn find_last_dir_sep(s: &str) -> Option<usize> {
s.bytes().rposition(is_dir_sep)
}
fn starts_with_dot_slash(s: &str) -> bool {
s.starts_with("./") || s.starts_with(".\\")
}
fn starts_with_dot_dot_slash(s: &str) -> bool {
s.starts_with("../") || s.starts_with("..\\")
}
fn chop_last_dir(remoteurl: &mut String, is_relative: bool) -> bool {
if let Some(idx) = find_last_dir_sep(remoteurl) {
remoteurl.truncate(idx);
return false;
}
if let Some(idx) = remoteurl.rfind(':') {
remoteurl.truncate(idx);
return true;
}
let _ = is_relative;
*remoteurl = ".".to_string();
false
}
pub fn relative_url(remote_url: &str, url: &str, up_path: Option<&str>) -> String {
if !url_is_local_not_ssh(url) || is_absolute_path(url) {
return url.to_string();
}
let mut remoteurl = if remote_url.is_empty() {
".".to_string()
} else {
remote_url.to_string()
};
if remoteurl.as_bytes().last().copied().is_some_and(is_dir_sep) {
remoteurl.pop();
}
let mut is_relative = false;
if url_is_local_not_ssh(&remoteurl) && !is_absolute_path(&remoteurl) {
is_relative = true;
if !starts_with_dot_slash(&remoteurl) && !starts_with_dot_dot_slash(&remoteurl) {
remoteurl = format!("./{remoteurl}");
}
}
let mut colonsep = false;
let mut rest = url;
loop {
if starts_with_dot_dot_slash(rest) {
rest = &rest[3..];
colonsep |= chop_last_dir(&mut remoteurl, is_relative);
} else if starts_with_dot_slash(rest) {
rest = &rest[2..];
} else {
break;
}
}
let sep = if colonsep { ":" } else { "/" };
let mut out = format!("{remoteurl}{sep}{rest}");
if rest.ends_with('/') {
out.pop();
}
let out = if starts_with_dot_slash(&out) {
out[2..].to_string()
} else {
out
};
match up_path {
Some(up) if is_relative => format!("{up}{out}"),
_ => out,
}
}
pub fn resolve_relative_url(
rel_url: &str,
base: Option<&str>,
cwd_fallback: &str,
up_path: Option<&str>,
) -> String {
let remoteurl = base.unwrap_or(cwd_fallback);
relative_url(remoteurl, rel_url, up_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn t7400_relative_url_matrix() {
let cases = [
(
"ssh://hostname/repo",
"../subrepo",
"ssh://hostname/subrepo",
),
(
"ssh://hostname:22/repo",
"../subrepo",
"ssh://hostname:22/subrepo",
),
(
"//somewhere else/repo",
"../subrepo",
"//somewhere else/subrepo",
),
("file:///tmp/repo", "../subrepo", "file:///tmp/subrepo"),
(
"helper:://hostname/repo",
"../subrepo",
"helper:://hostname/subrepo",
),
("user@host:repo", "../subrepo", "user@host:subrepo"),
(
"user@host:path/to/repo",
"../subrepo",
"user@host:path/to/subrepo",
),
("foo", "../subrepo", "subrepo"),
("foo/bar", "../subrepo", "foo/subrepo"),
("./foo", "../subrepo", "subrepo"),
("./foo/bar", "../subrepo", "foo/subrepo"),
("../foo", "../subrepo", "../subrepo"),
("../foo/bar", "../subrepo", "../foo/subrepo"),
];
for (base, url, want) in cases {
assert_eq!(relative_url(base, url, None), want, "base={base} url={url}");
}
}
#[test]
fn deeper_relative_path() {
assert_eq!(
relative_url("../foo/bar.git", "../bar/a/b/c", None),
"../foo/bar/a/b/c"
);
}
#[test]
fn absolute_and_ssh_urls_passthrough() {
assert_eq!(relative_url("/base", "/abs/path", None), "/abs/path");
assert_eq!(relative_url("/base", "ssh://h/repo", None), "ssh://h/repo");
assert_eq!(
relative_url("/base", "https://h/repo", None),
"https://h/repo"
);
}
#[test]
fn absolute_local_base_resolution() {
assert_eq!(
relative_url("/tmp/x/super", "../submodule", None),
"/tmp/x/submodule"
);
}
#[test]
fn up_path_prefix_only_for_relative_base() {
assert_eq!(
relative_url("./foo/bar", "../sub", Some("../")),
"../foo/sub"
);
assert_eq!(
relative_url("/tmp/x/super", "../sub", Some("../../")),
"/tmp/x/sub"
);
}
}