crates/gunnar-ui-web/src/addr.rs
//! **The console's one address scheme** (GUN-GUEST, owner 2026-10-30).
//!
//! A repository is `/{ns}/{name}`, and everything about it hangs under that:
//! `/tree/{path}` and `/blob/{path}` (the file view, one page for a directory
//! or a file whatever the word says), `/commits` (the history), `/issues`,
//! `/issues/{n}`, `/pulls`, `/pulls/{n}`, `/diff`, `/blame`, `/search`,
//! `/chain`, `/activity` and `/settings`. A person clicking a search result
//! lands on the same address a crawler read, and the console draws it.
//!
//! The old `/repo/{ns}/{name}…` addresses lead to the new ones ([`from_old`]):
//! the server answers them `301`, and the console's router takes the same
//! road for a link it still holds. `files` is `tree` (`files?path=p` is
//! `tree/p`), `graph` is `commits`, every other leaf is the same word, and the
//! rest of the query is kept.
//!
//! Pure functions and no `web-sys`, so all of it is tested on the host.
/// One path segment, percent-encoded (a `/` in a name is `%2F`).
pub fn seg(value: &str) -> String {
crate::auth::percent_encode(value).replace('/', "%2F")
}
/// A file path in a repository as an address tail: each segment encoded, the
/// `/` between them kept.
pub fn file_path(path: &str) -> String {
path.trim_matches('/').split('/').filter(|s| !s.is_empty()).map(seg).collect::<Vec<_>>().join("/")
}
/// `/{ns}/{name}`: the repository's home (its README).
pub fn repo(namespace: &str, name: &str) -> String {
format!("/{}/{}", seg(namespace), seg(name))
}
/// `/{ns}/{name}/{leaf}`: one of the repository's pages.
pub fn leaf(namespace: &str, name: &str, leaf: &str) -> String {
format!("{}/{leaf}", repo(namespace, name))
}
fn with_rev(mut url: String, rev: &str) -> String {
if !rev.is_empty() {
url.push_str("?rev=");
url.push_str(&crate::auth::percent_encode(rev));
}
url
}
/// `/{ns}/{name}/tree[/{path}][?rev=…]`: a directory (the root when `path` is empty).
pub fn tree(namespace: &str, name: &str, path: &str, rev: &str) -> String {
let tail = file_path(path);
let base = leaf(namespace, name, "tree");
with_rev(if tail.is_empty() { base } else { format!("{base}/{tail}") }, rev)
}
/// `/{ns}/{name}/blob/{path}[?rev=…]`: one file.
pub fn blob(namespace: &str, name: &str, path: &str, rev: &str) -> String {
let tail = file_path(path);
if tail.is_empty() {
return tree(namespace, name, "", rev);
}
with_rev(format!("{}/{tail}", leaf(namespace, name, "blob")), rev)
}
/// The repository path a `/tree/*` or `/blob/*` address names, from the
/// router's wildcard. The browser's location is decoded with `decodeURI`,
/// which leaves the escapes of the reserved characters alone, so those (and
/// only those) are decoded here: a file called `a#b` or `x?y` keeps its name.
pub fn path_param(raw: &str) -> String {
const LEFT: &[(&str, char)] = &[
("%23", '#'),
("%24", '$'),
("%26", '&'),
("%2B", '+'),
("%2C", ','),
("%2F", '/'),
("%3A", ':'),
("%3B", ';'),
("%3D", '='),
("%3F", '?'),
("%40", '@'),
];
let mut out = String::with_capacity(raw.len());
let mut rest = raw.trim_matches('/');
while let Some(at) = rest.find('%') {
out.push_str(&rest[..at]);
let code = rest.get(at..at + 3).map(str::to_ascii_uppercase);
match code.as_deref().and_then(|c| LEFT.iter().find(|(k, _)| *k == c)) {
Some((_, c)) => {
out.push(*c);
rest = &rest[at + 3..];
}
None => {
out.push('%');
rest = &rest[at + 1..];
}
}
}
out.push_str(rest);
out
}
/// **Where an old `/repo/…` address leads now** (path and query as the
/// browser holds them), or `None` for any other address. The server's 301
/// is the same mapping.
pub fn from_old(path: &str, query: &str) -> Option<String> {
let rest = path.strip_prefix("/repo")?;
if !(rest.is_empty() || rest.starts_with('/')) {
return None;
}
let parts: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
let pairs: Vec<&str> = query.trim_start_matches('?').split('&').filter(|p| !p.is_empty()).collect();
let rejoin = |kept: Vec<&str>| if kept.is_empty() { String::new() } else { format!("?{}", kept.join("&")) };
let to = match parts.as_slice() {
[] => "/repos".to_owned(),
[ns] => format!("/{ns}"),
[ns, name] => format!("/{ns}/{name}"),
[ns, name, "files", ..] => {
// `?path=` moves into the address; every other parameter stays.
let at = pairs.iter().find_map(|p| p.strip_prefix("path=")).unwrap_or_default();
let kept: Vec<&str> = pairs.iter().copied().filter(|p| !p.starts_with("path=")).collect();
let tail = at.replace("%2F", "/").replace("%2f", "/");
let tail = tail.trim_matches('/');
let base = if tail.is_empty() { format!("/{ns}/{name}/tree") } else { format!("/{ns}/{name}/tree/{tail}") };
return Some(format!("{base}{}", rejoin(kept)));
}
[ns, name, "graph", more @ ..] => format!("/{ns}/{name}/commits{}", more.iter().map(|m| format!("/{m}")).collect::<String>()),
[ns, name, more @ ..] => format!("/{ns}/{name}/{}", more.join("/")),
};
Some(format!("{to}{}", rejoin(pairs)))
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ GUN-GUEST: a repository is `/{ns}/{name}`, its files `/tree/…` and `/blob/…`.
#[test]
fn a_repository_and_its_files_have_the_one_address_scheme() {
assert_eq!(repo("nordisk", "gunnar"), "/nordisk/gunnar");
assert_eq!(leaf("nordisk", "gunnar", "commits"), "/nordisk/gunnar/commits");
assert_eq!(tree("nordisk", "gunnar", "", ""), "/nordisk/gunnar/tree");
assert_eq!(tree("nordisk", "gunnar", "docs/v2", ""), "/nordisk/gunnar/tree/docs/v2");
assert_eq!(blob("nordisk", "holger", "docs/v2/README.md", ""), "/nordisk/holger/blob/docs/v2/README.md");
assert_eq!(blob("a b", "c", "dir x/ü#?.md", "v1.0"), "/a%20b/c/blob/dir%20x/%C3%BC%23%3F.md?rev=v1.0");
assert_eq!(blob("n", "r", "", ""), "/n/r/tree", "no path is the root directory");
assert_eq!(repo("o/dd", "r"), "/o%2Fdd/r", "a name is one segment");
}
/// ★ The wildcard's path, as the router hands it, is the repository path again.
#[test]
fn an_address_tail_reads_back_as_the_file_path() {
assert_eq!(path_param("docs/v2/README.md"), "docs/v2/README.md");
assert_eq!(path_param("/docs/"), "docs");
assert_eq!(path_param(""), "");
// decodeURI left these; the file's own name has them.
assert_eq!(path_param("dir x/%23%3F%2f.md"), "dir x/#?/.md");
assert_eq!(path_param("100%.txt"), "100%.txt");
}
/// ★ Every old address leads to its new home, query kept.
#[test]
fn every_old_repo_address_leads_to_the_new_one() {
let to = |p: &str, q: &str| from_old(p, q);
assert_eq!(to("/repo/nordisk/gunnar", "").as_deref(), Some("/nordisk/gunnar"));
assert_eq!(to("/repo/nordisk/gunnar/", "").as_deref(), Some("/nordisk/gunnar"));
assert_eq!(to("/repo/nordisk/gunnar/files", "").as_deref(), Some("/nordisk/gunnar/tree"));
assert_eq!(to("/repo/nordisk/holger/files", "path=docs%2Fv2%2FREADME.md").as_deref(), Some("/nordisk/holger/tree/docs/v2/README.md"));
assert_eq!(to("/repo/nordisk/holger/files", "?rev=main&path=docs/x.md").as_deref(), Some("/nordisk/holger/tree/docs/x.md?rev=main"));
assert_eq!(to("/repo/n/r/graph", "rev=main").as_deref(), Some("/n/r/commits?rev=main"));
assert_eq!(to("/repo/n/r/issues/7", "").as_deref(), Some("/n/r/issues/7"));
assert_eq!(to("/repo/n/r/diff", "from=a&to=b").as_deref(), Some("/n/r/diff?from=a&to=b"));
for leaf in ["pulls", "pulls/3", "issues", "blame", "search", "chain", "activity", "settings"] {
assert_eq!(to(&format!("/repo/n/r/{leaf}"), "").as_deref(), Some(format!("/n/r/{leaf}").as_str()), "{leaf}");
}
assert_eq!(to("/repo/a%20b/c", "").as_deref(), Some("/a%20b/c"), "names stay encoded");
assert_eq!(to("/repo", "").as_deref(), Some("/repos"));
assert_eq!(to("/repo/nordisk", "").as_deref(), Some("/nordisk"));
for not in ["/repos", "/repository/a/b", "/nordisk/gunnar", "/"] {
assert_eq!(to(not, ""), None, "{not}");
}
}
}