#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Route {
Index,
OwnerIndex {
owner: String,
},
GitSmart {
rel_path: String,
},
RepoOverview {
owner: String,
repo: String,
},
Tree {
owner: String,
repo: String,
rev: String,
path: String,
},
Blob {
owner: String,
repo: String,
rev: String,
path: String,
},
Raw {
owner: String,
repo: String,
rev: String,
path: String,
},
Commits {
owner: String,
repo: String,
rev: String,
},
Commit {
owner: String,
repo: String,
sha: String,
},
Branches {
owner: String,
repo: String,
},
Tags {
owner: String,
repo: String,
},
Issues {
owner: String,
repo: String,
},
IssueNew {
owner: String,
repo: String,
},
IssueDetail {
owner: String,
repo: String,
num: u64,
},
ApiToken,
ApiHosted {
hex: String,
id: String,
},
ApiReposList,
ApiRepo {
owner: String,
repo: String,
},
NotFound,
}
const GIT_SUFFIXES: &[&str] = &["/info/refs", "/git-upload-pack", "/git-receive-pack"];
#[must_use]
pub fn strip_prefix<'a>(prefix: &str, path: &'a str) -> Option<std::borrow::Cow<'a, str>> {
if prefix.is_empty() {
return Some(std::borrow::Cow::Borrowed(path));
}
if let Some(rest) = path.strip_prefix(prefix) {
if rest.is_empty() {
Some(std::borrow::Cow::Borrowed("/"))
} else if rest.starts_with('/') {
Some(std::borrow::Cow::Borrowed(rest))
} else {
None
}
} else {
None
}
}
#[must_use]
pub fn parse_route(rel: &str) -> Route {
let rel = rel.split('?').next().unwrap_or(rel);
if rel.is_empty() || rel == "/" {
return Route::Index;
}
for suffix in GIT_SUFFIXES {
if rel.ends_with(suffix) {
return Route::GitSmart {
rel_path: rel.to_string(),
};
}
}
let segs: Vec<&str> = rel
.trim_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
if segs.is_empty() {
return Route::Index;
}
if segs[0] == "api" {
return parse_api(&segs[1..]);
}
match segs.as_slice() {
[owner] => Route::OwnerIndex {
owner: (*owner).to_string(),
},
[owner, repo] => Route::RepoOverview {
owner: (*owner).to_string(),
repo: strip_git_suffix(repo),
},
[owner, repo, verb, rest @ ..] => parse_repo_verb(owner, repo, verb, rest),
_ => Route::NotFound,
}
}
fn parse_repo_verb(owner: &str, repo: &str, verb: &str, rest: &[&str]) -> Route {
let owner = owner.to_string();
let repo = strip_git_suffix(repo);
match verb {
"tree" | "blob" | "raw" => {
if rest.is_empty() {
return Route::NotFound;
}
let rev = rest[0].to_string();
let path = rest[1..].join("/");
match verb {
"tree" => Route::Tree {
owner,
repo,
rev,
path,
},
"blob" => Route::Blob {
owner,
repo,
rev,
path,
},
_ => Route::Raw {
owner,
repo,
rev,
path,
},
}
}
"commits" => {
let rev = rest.first().map(|s| (*s).to_string()).unwrap_or_default();
if rev.is_empty() {
return Route::NotFound;
}
Route::Commits { owner, repo, rev }
}
"commit" => {
let sha = rest.first().map(|s| (*s).to_string()).unwrap_or_default();
if sha.is_empty() {
return Route::NotFound;
}
Route::Commit { owner, repo, sha }
}
"branches" if rest.is_empty() => Route::Branches { owner, repo },
"tags" if rest.is_empty() => Route::Tags { owner, repo },
"issues" => match rest {
[] => Route::Issues { owner, repo },
["new"] => Route::IssueNew { owner, repo },
[num] => match num.parse::<u64>() {
Ok(n) => Route::IssueDetail {
owner,
repo,
num: n,
},
Err(_) => Route::NotFound,
},
_ => Route::NotFound,
},
_ => Route::NotFound,
}
}
fn parse_api(segs: &[&str]) -> Route {
match segs {
["token"] => Route::ApiToken,
["hosted", hex, id] => Route::ApiHosted {
hex: (*hex).to_string(),
id: (*id).to_string(),
},
["repos"] => Route::ApiReposList,
["repos", owner, repo] => Route::ApiRepo {
owner: (*owner).to_string(),
repo: strip_git_suffix(repo),
},
_ => Route::NotFound,
}
}
#[must_use]
pub fn strip_git_suffix(repo: &str) -> String {
repo.strip_suffix(".git").unwrap_or(repo).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_prefix_variants() {
assert_eq!(strip_prefix("/forge", "/forge").as_deref(), Some("/"));
assert_eq!(
strip_prefix("/forge", "/forge/alice/repo").as_deref(),
Some("/alice/repo")
);
assert_eq!(strip_prefix("/forge", "/forgery").as_deref(), None);
assert_eq!(strip_prefix("/forge", "/other").as_deref(), None);
assert_eq!(
strip_prefix("", "/alice/repo").as_deref(),
Some("/alice/repo")
);
}
#[test]
fn index_route() {
assert_eq!(parse_route("/"), Route::Index);
assert_eq!(parse_route(""), Route::Index);
}
#[test]
fn git_smart_routes_take_priority() {
assert_eq!(
parse_route("/alice/repo.git/info/refs"),
Route::GitSmart {
rel_path: "/alice/repo.git/info/refs".into()
}
);
assert_eq!(
parse_route("/alice/repo.git/git-receive-pack"),
Route::GitSmart {
rel_path: "/alice/repo.git/git-receive-pack".into()
}
);
}
#[test]
fn owner_and_repo_overview() {
assert_eq!(
parse_route("/alice"),
Route::OwnerIndex {
owner: "alice".into()
}
);
assert_eq!(
parse_route("/alice/myrepo"),
Route::RepoOverview {
owner: "alice".into(),
repo: "myrepo".into()
}
);
assert_eq!(
parse_route("/alice/myrepo.git"),
Route::RepoOverview {
owner: "alice".into(),
repo: "myrepo".into()
}
);
}
#[test]
fn tree_blob_raw_split_rev_and_path() {
assert_eq!(
parse_route("/alice/repo/tree/main/src/lib.rs"),
Route::Tree {
owner: "alice".into(),
repo: "repo".into(),
rev: "main".into(),
path: "src/lib.rs".into()
}
);
assert_eq!(
parse_route("/alice/repo/tree/main"),
Route::Tree {
owner: "alice".into(),
repo: "repo".into(),
rev: "main".into(),
path: String::new()
}
);
assert_eq!(
parse_route("/alice/repo/raw/main/bin/data"),
Route::Raw {
owner: "alice".into(),
repo: "repo".into(),
rev: "main".into(),
path: "bin/data".into()
}
);
}
#[test]
fn commit_and_commits() {
assert_eq!(
parse_route("/alice/repo/commits/main"),
Route::Commits {
owner: "alice".into(),
repo: "repo".into(),
rev: "main".into()
}
);
assert_eq!(
parse_route("/alice/repo/commit/deadbeef"),
Route::Commit {
owner: "alice".into(),
repo: "repo".into(),
sha: "deadbeef".into()
}
);
}
#[test]
fn issues_routes() {
assert_eq!(
parse_route("/alice/repo/issues"),
Route::Issues {
owner: "alice".into(),
repo: "repo".into()
}
);
assert_eq!(
parse_route("/alice/repo/issues/new"),
Route::IssueNew {
owner: "alice".into(),
repo: "repo".into()
}
);
assert_eq!(
parse_route("/alice/repo/issues/42"),
Route::IssueDetail {
owner: "alice".into(),
repo: "repo".into(),
num: 42
}
);
assert_eq!(parse_route("/alice/repo/issues/notanum"), Route::NotFound);
}
#[test]
fn api_routes() {
assert_eq!(parse_route("/api/token"), Route::ApiToken);
assert_eq!(parse_route("/api/repos"), Route::ApiReposList);
assert_eq!(
parse_route("/api/repos/alice/repo"),
Route::ApiRepo {
owner: "alice".into(),
repo: "repo".into()
}
);
assert_eq!(
parse_route("/api/hosted/abc/xyz"),
Route::ApiHosted {
hex: "abc".into(),
id: "xyz".into()
}
);
}
#[test]
fn unknown_is_not_found() {
assert_eq!(parse_route("/alice/repo/bogusverb/x"), Route::NotFound);
assert_eq!(parse_route("/api/unknown"), Route::NotFound);
}
}