use crate::api::browse::{DiscussionsOf, RepoSort, SearchKind};
use crate::api::model::{PrRef, RepoId};
use crate::git::Oid;
use crate::ui::pages::url as links;
use crate::ui::pages::{PrTab, ProfileTab};
use crate::ui::text::short_sha;
use crate::diff_screen::DiffOf;
pub const OPEN: &str = "is:open";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Route {
Home,
Repo(RepoId),
Tree {
repo: RepoId,
rev: String,
path: String,
},
Blob {
repo: RepoId,
rev: String,
path: String,
lines: Option<(u32, u32)>,
},
Blame {
repo: RepoId,
rev: String,
path: String,
lines: Option<(u32, u32)>,
},
Issues {
repo: RepoId,
query: String,
},
Pulls {
repo: RepoId,
query: String,
},
Issue {
repo: RepoId,
number: u64,
},
Pr {
pr: PrRef,
tab: PrTab,
},
Actions(RepoId),
Discussions {
of: DiscussionsOf,
category: Option<String>,
},
Discussion {
of: DiscussionsOf,
number: u64,
},
WorkflowRun {
repo: RepoId,
run: u64,
attempt: Option<u64>,
},
Job {
repo: RepoId,
run: Option<u64>,
job: u64,
step: Option<(u32, u32)>,
query: String,
},
Workflow {
repo: RepoId,
file: String,
},
CommitChecks {
repo: RepoId,
oid: String,
},
Stargazers(RepoId),
Watchers(RepoId),
Forks(RepoId),
Releases(RepoId),
Release {
repo: RepoId,
tag: String,
},
Tags(RepoId),
Branches(RepoId),
Wiki {
repo: RepoId,
page: Option<String>,
},
Advisories(Option<RepoId>),
Advisory {
repo: Option<RepoId>,
ghsa: String,
},
Teams(String),
Team {
org: String,
slug: String,
},
Gist {
owner: Option<String>,
id: String,
},
Gists(String),
Compare {
repo: RepoId,
spec: String,
},
Deployments {
repo: RepoId,
environment: Option<String>,
},
Milestones {
repo: RepoId,
closed: bool,
},
Milestone {
repo: RepoId,
number: u64,
},
Unsplit {
repo: RepoId,
view: RevView,
spot: String,
lines: Option<(u32, u32)>,
},
Commits {
repo: RepoId,
rev: String,
path: String,
},
Commit {
repo: RepoId,
oid: String,
},
User {
login: String,
tab: ProfileTab,
},
Search {
kind: SearchKind,
query: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RevView {
Tree,
Blob,
Blame,
Commits,
}
impl RevView {
fn name(self) -> &'static str {
match self {
RevView::Tree => "tree",
RevView::Blob => "blob",
RevView::Blame => "blame",
RevView::Commits => "commits",
}
}
fn at(
self,
repo: RepoId,
rev: String,
path: String,
lines: Option<(u32, u32)>,
) -> Option<Route> {
Some(match self {
RevView::Tree => Route::Tree { repo, rev, path },
RevView::Blob => Route::Blob {
repo,
rev,
path,
lines,
},
RevView::Blame if path.is_empty() => return None,
RevView::Blame => Route::Blame {
repo,
rev,
path,
lines,
},
RevView::Commits => Route::Commits { repo, rev, path },
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Page(Route),
Files(DiffOf),
External(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dest {
pub target: Target,
pub anchor: Option<String>,
}
impl From<Target> for Dest {
fn from(target: Target) -> Dest {
Dest {
target,
anchor: None,
}
}
}
impl Route {
pub fn pr(pr: PrRef) -> Route {
Route::Pr {
pr,
tab: PrTab::Conversation,
}
}
pub fn blob(repo: RepoId, rev: String, path: String) -> Route {
Route::Blob {
repo,
rev,
path,
lines: None,
}
}
pub fn split(&self, rev: Option<&str>) -> Option<Route> {
let Route::Unsplit {
repo,
view,
spot,
lines,
} = self
else {
return None;
};
let after = |rev: &str| {
let rest = spot.strip_prefix(rev)?;
match rest.strip_prefix('/') {
Some(path) => Some(path.to_owned()),
None => rest.is_empty().then(String::new),
}
};
let at = |rev: &str| view.at(repo.clone(), rev.to_owned(), after(rev)?, *lines);
let first = spot.split('/').next().unwrap_or_default();
rev.and_then(at).or_else(|| at(first))
}
pub fn rev(&self) -> Option<&str> {
match self {
Route::Tree { rev, .. }
| Route::Blob { rev, .. }
| Route::Blame { rev, .. }
| Route::Commits { rev, .. } => Some(rev),
_ => None,
}
}
pub fn user(login: &str) -> Route {
Route::User {
login: login.to_owned(),
tab: ProfileTab::Overview,
}
}
pub fn url(&self) -> String {
match self {
Route::Home => format!("{}/", links::BASE),
Route::Repo(repo) => links::repo(repo),
Route::Tree { repo, rev, path } => links::tree(repo, rev, path),
Route::Blob {
repo,
rev,
path,
lines,
} => lined(links::blob(repo, rev, path), *lines),
Route::Blame {
repo,
rev,
path,
lines,
} => lined(links::blame(repo, rev, path), *lines),
Route::Unsplit {
repo,
view,
spot,
lines,
} => lined(
format!(
"{}/{}/{}",
links::repo(repo),
view.name(),
links::encode_path(spot)
),
*lines,
),
Route::Issues { repo, query } => list_url(&links::issues(repo), query),
Route::Pulls { repo, query } => list_url(&links::pulls(repo), query),
Route::Issue { repo, number } => links::issue(repo, *number),
Route::Pr { pr, tab } => match tab {
PrTab::Conversation => links::pull(pr),
PrTab::Commits => links::pull_tab(pr, "commits"),
PrTab::Checks => links::pull_tab(pr, "checks"),
},
Route::Actions(repo) => format!("{}/actions", links::repo(repo)),
Route::WorkflowRun { repo, run, attempt } => {
let url = format!("{}/actions/runs/{run}", links::repo(repo));
match attempt {
Some(n) => format!("{url}/attempts/{n}"),
None => url,
}
}
Route::Job {
repo,
run,
job,
step,
query,
} => {
let mut url = match run {
Some(run) => format!("{}/actions/runs/{run}/job/{job}", links::repo(repo)),
None => format!("{}/runs/{job}", links::repo(repo)),
};
if !query.is_empty() {
url.push_str(&format!("?q={}", links::encode(query)));
}
if let Some((step, line)) = step {
url.push_str(&format!("#step:{step}:{line}"));
}
url
}
Route::Workflow { repo, file } => {
format!(
"{}/actions/workflows/{}",
links::repo(repo),
links::encode_path(file)
)
}
Route::CommitChecks { repo, oid } => format!("{}/checks", links::commit(repo, oid)),
Route::Stargazers(repo) => format!("{}/stargazers", links::repo(repo)),
Route::Watchers(repo) => format!("{}/watchers", links::repo(repo)),
Route::Forks(repo) => format!("{}/forks", links::repo(repo)),
Route::Releases(repo) => format!("{}/releases", links::repo(repo)),
Route::Release { repo, tag } => links::release(repo, tag),
Route::Discussions { of, category } => match category {
Some(slug) => format!(
"{}/categories/{}",
discussions_url(of),
links::encode_path(slug)
),
None => discussions_url(of),
},
Route::Discussion { of, number } => format!("{}/{number}", discussions_url(of)),
Route::Tags(repo) => format!("{}/tags", links::repo(repo)),
Route::Branches(repo) => format!("{}/branches", links::repo(repo)),
Route::Wiki { repo, page } => match page {
Some(page) => format!("{}/wiki/{}", links::repo(repo), links::encode_path(page)),
None => format!("{}/wiki", links::repo(repo)),
},
Route::Advisories(repo) => match repo {
Some(repo) => format!("{}/security/advisories", links::repo(repo)),
None => format!("{}/advisories", links::BASE),
},
Route::Advisory { repo, ghsa } => match repo {
Some(repo) => format!("{}/security/advisories/{ghsa}", links::repo(repo)),
None => format!("{}/advisories/{ghsa}", links::BASE),
},
Route::Teams(org) => format!("{}/orgs/{org}/teams", links::BASE),
Route::Team { org, slug } => format!("{}/orgs/{org}/teams/{slug}", links::BASE),
Route::Gist { owner, id } => match owner {
Some(owner) => format!("{GIST}/{owner}/{id}"),
None => format!("{GIST}/{id}"),
},
Route::Gists(login) => format!("{GIST}/{login}"),
Route::Compare { repo, spec } => {
format!("{}/compare/{}", links::repo(repo), links::encode_path(spec))
}
Route::Deployments { repo, environment } => match environment {
Some(env) => format!(
"{}/deployments/activity_log?environments_filter={}",
links::repo(repo),
links::encode(env)
),
None => format!("{}/deployments", links::repo(repo)),
},
Route::Milestones { repo, closed } => format!(
"{}/milestones{}",
links::repo(repo),
if *closed { "?state=closed" } else { "" }
),
Route::Milestone { repo, number } => {
format!("{}/milestone/{number}", links::repo(repo))
}
Route::Commits { repo, rev, path } => links::commits(repo, rev, path),
Route::Commit { repo, oid } => links::commit(repo, oid),
Route::User { login, tab } => match tab {
ProfileTab::Overview => links::user(login),
ProfileTab::Repositories(sort) => {
let sort = match sort {
RepoSort::Updated => "",
RepoSort::Name => "&sort=name",
RepoSort::Stars => "&sort=stargazers",
};
format!("{}?tab=repositories{sort}", links::user(login))
}
ProfileTab::Stars => format!("{}?tab=stars", links::user(login)),
ProfileTab::Followers => format!("{}?tab=followers", links::user(login)),
ProfileTab::Following => format!("{}?tab=following", links::user(login)),
ProfileTab::People => format!("{}/orgs/{login}/people", links::BASE),
},
Route::Search { kind, query } => links::search(*kind, query),
}
}
pub fn title(&self) -> String {
match self {
Route::Home => "Home".to_owned(),
Route::Repo(repo) => repo.to_string(),
Route::Tree { repo, path, .. } if path.is_empty() => repo.to_string(),
Route::Tree { repo, path, .. } | Route::Blob { repo, path, .. } => {
format!("{}/{path}", repo.name)
}
Route::Blame { repo, path, .. } => format!("{}/{path} · Blame", repo.name),
Route::Unsplit { repo, spot, .. } => match self.split(None) {
Some(page) => page.title(),
None => format!("{}/{spot}", repo.name),
},
Route::Issues { repo, .. } => format!("{repo} · Issues"),
Route::Pulls { repo, .. } => format!("{repo} · Pull requests"),
Route::Issue { repo, number } => format!("{repo}#{number}"),
Route::Pr { pr, .. } => pr.to_string(),
Route::Actions(repo) => format!("{repo} · Actions"),
Route::WorkflowRun { repo, run, .. } => format!("{repo} · Run {run}"),
Route::Job { repo, job, .. } => format!("{repo} · Job {job}"),
Route::Workflow { repo, file } => format!("{repo} · {file}"),
Route::CommitChecks { repo, oid } => format!("{repo}@{} · Checks", short_sha(oid)),
Route::Stargazers(repo) => format!("{repo} · Stargazers"),
Route::Watchers(repo) => format!("{repo} · Watchers"),
Route::Forks(repo) => format!("{repo} · Forks"),
Route::Releases(repo) => format!("{repo} · Releases"),
Route::Release { repo, tag } => format!("{repo} {tag}"),
Route::Discussions { of, .. } => format!("{} · Discussions", of_title(of)),
Route::Discussion { of, number } => format!("{} · Discussion {number}", of_title(of)),
Route::Tags(repo) => format!("{repo} · Tags"),
Route::Branches(repo) => format!("{repo} · Branches"),
Route::Wiki { repo, page } => match page {
Some(page) => format!("{repo} · {}", page.replace('-', " ")),
None => format!("{repo} · Wiki"),
},
Route::Advisories(Some(repo)) => format!("{repo} · Security"),
Route::Advisories(None) => "Advisories".to_owned(),
Route::Advisory { ghsa, .. } => ghsa.clone(),
Route::Teams(org) => format!("@{org} · Teams"),
Route::Team { org, slug } => format!("@{org}/{slug}"),
Route::Gist { id, .. } => format!("Gist {}", id.get(..7).unwrap_or(id)),
Route::Gists(login) => format!("@{login} · Gists"),
Route::Compare { repo, spec } => format!("{repo} · {spec}"),
Route::Deployments { repo, .. } => format!("{repo} · Deployments"),
Route::Milestones { repo, .. } => format!("{repo} · Milestones"),
Route::Milestone { repo, number } => format!("{repo} · Milestone {number}"),
Route::Commits { repo, path, .. } if path.is_empty() => format!("{repo} · Commits"),
Route::Commits { repo, path, .. } => format!("{}/{path} · Commits", repo.name),
Route::Commit { repo, oid } => format!("{repo}@{}", short_sha(oid)),
Route::User { login, .. } => format!("@{login}"),
Route::Search { query, .. } => format!("Search “{query}”"),
}
}
pub fn repo(&self) -> Option<&RepoId> {
match self {
Route::Repo(repo)
| Route::Tree { repo, .. }
| Route::Blob { repo, .. }
| Route::Issues { repo, .. }
| Route::Pulls { repo, .. }
| Route::Issue { repo, .. }
| Route::Commits { repo, .. }
| Route::Actions(repo)
| Route::WorkflowRun { repo, .. }
| Route::Job { repo, .. }
| Route::Workflow { repo, .. }
| Route::CommitChecks { repo, .. }
| Route::Stargazers(repo)
| Route::Watchers(repo)
| Route::Forks(repo)
| Route::Releases(repo)
| Route::Release { repo, .. }
| Route::Discussions {
of: DiscussionsOf::Repo(repo),
..
}
| Route::Discussion {
of: DiscussionsOf::Repo(repo),
..
}
| Route::Tags(repo)
| Route::Branches(repo)
| Route::Wiki { repo, .. }
| Route::Advisories(Some(repo))
| Route::Advisory {
repo: Some(repo), ..
}
| Route::Blame { repo, .. }
| Route::Unsplit { repo, .. }
| Route::Compare { repo, .. }
| Route::Deployments { repo, .. }
| Route::Milestones { repo, .. }
| Route::Milestone { repo, .. }
| Route::Commit { repo, .. } => Some(repo),
Route::Pr { pr, .. } => Some(&pr.repo),
Route::Home
| Route::User { .. }
| Route::Search { .. }
| Route::Gist { .. }
| Route::Gists(_)
| Route::Teams(_)
| Route::Team { .. }
| Route::Advisories(None)
| Route::Advisory { repo: None, .. }
| Route::Discussions {
of: DiscussionsOf::Org(_),
..
}
| Route::Discussion {
of: DiscussionsOf::Org(_),
..
} => None,
}
}
pub fn list_query(&self) -> Option<&str> {
match self {
Route::Issues { query, .. }
| Route::Pulls { query, .. }
| Route::Search { query, .. } => Some(query),
_ => None,
}
}
pub fn with_query(&self, query: String) -> Option<Route> {
Some(match self {
Route::Issues { repo, .. } => Route::Issues {
repo: repo.clone(),
query,
},
Route::Pulls { repo, .. } => Route::Pulls {
repo: repo.clone(),
query,
},
Route::Search { kind, .. } => Route::Search { kind: *kind, query },
_ => return None,
})
}
pub fn search(&self) -> Option<(SearchKind, String)> {
match self {
Route::Issues { repo, query } => {
Some((SearchKind::Issues, list_query(repo, "issue", query)))
}
Route::Pulls { repo, query } => {
Some((SearchKind::Issues, list_query(repo, "pr", query)))
}
Route::Search { kind, query } => Some((*kind, query.clone())),
_ => None,
}
}
}
fn list_query(repo: &RepoId, is: &str, query: &str) -> String {
let mut q = format!("repo:{repo} is:{is} {}", query.trim());
if !query.contains("sort:") {
q.push_str(" sort:created-desc");
}
q
}
fn list_url(base: &str, query: &str) -> String {
if query == OPEN {
base.to_owned()
} else {
format!("{base}?q={}", links::encode(query))
}
}
impl Dest {
pub fn from_url(s: &str) -> Dest {
let with_scheme = if s.starts_with("github.com/") {
format!("https://{s}")
} else {
s.to_owned()
};
let parsed = url::Url::parse(&with_scheme).ok();
let anchor = parsed
.as_ref()
.and_then(url::Url::fragment)
.map(str::to_owned);
match parsed.as_ref().and_then(parse) {
Some(target) => Dest { target, anchor },
None => Target::External(s.to_owned()).into(),
}
}
}
fn parse(parsed: &url::Url) -> Option<Target> {
if !matches!(parsed.scheme(), "https" | "http") {
return None;
}
if parsed.host_str() == Some("gist.github.com") {
return gist(parsed).map(Target::Page);
}
if !matches!(parsed.host_str(), Some("github.com" | "www.github.com")) {
return None;
}
let param = |name: &str| param(parsed, name);
let (query, kind, tab) = (param("q"), param("type"), param("tab"));
let repos = || {
ProfileTab::Repositories(match param("sort").as_deref() {
Some("name") => RepoSort::Name,
Some("stargazers") => RepoSort::Stars,
_ => RepoSort::Updated,
})
};
let segments: Vec<String> = parsed
.path_segments()
.map(|s| s.filter(|p| !p.is_empty()).map(decode).collect())
.unwrap_or_default();
let segments: Vec<&str> = segments.iter().map(String::as_str).collect();
let route = match segments.as_slice() {
[] | ["dashboard"] => Route::Home,
["search"] => Route::Search {
kind: match kind.as_deref().map(str::to_ascii_lowercase).as_deref() {
None | Some("repositories") => SearchKind::Repos,
Some("issues") => SearchKind::Issues,
Some("pullrequests") => SearchKind::Pulls,
Some("users") => SearchKind::Users,
Some("discussions") => SearchKind::Discussions,
Some("commits") => SearchKind::Commits,
Some("code") => SearchKind::Code,
Some("topics") => {
let topic = query.unwrap_or_default();
let topic = topic.trim();
let query = if topic.is_empty() {
String::new()
} else {
format!("topic:{topic}")
};
return Some(Target::Page(Route::Search {
kind: SearchKind::Repos,
query,
}));
}
Some(_) => return None,
},
query: query.unwrap_or_default(),
},
["topics", topic] => Route::Search {
kind: SearchKind::Repos,
query: format!("topic:{topic}"),
},
[list @ ("pulls" | "issues"), rest @ ..] => {
let (kind, is) = if *list == "pulls" {
(SearchKind::Pulls, "pr")
} else {
(SearchKind::Issues, "issue")
};
let whose = match rest {
[] => "author:@me",
["assigned"] => "assignee:@me",
["mentioned"] => "mentions:@me",
["review-requested"] if *list == "pulls" => "review-requested:@me",
_ => return None,
};
Route::Search {
kind,
query: query.unwrap_or_else(|| format!("is:open is:{is} {whose} archived:false")),
}
}
["stars" | "orgs", login, ..] if !valid_login(login) => return None,
["stars", login] => Route::User {
login: (*login).to_owned(),
tab: ProfileTab::Stars,
},
["orgs", login] => Route::user(login),
["advisories"] => Route::Advisories(None),
["advisories", ghsa] if valid_ghsa(ghsa) => Route::Advisory {
repo: None,
ghsa: (*ghsa).to_owned(),
},
["orgs", org, "teams"] => Route::Teams((*org).to_owned()),
["orgs", _, "teams", "new"] => return None,
["orgs", org, "teams", slug, ..] if valid_slug(slug) => Route::Team {
org: (*org).to_owned(),
slug: (*slug).to_owned(),
},
["orgs", org, "discussions", rest @ ..] => {
discussions(DiscussionsOf::Org((*org).to_owned()), rest)?
}
["orgs", login, page @ ("repositories" | "people")] => Route::User {
login: (*login).to_owned(),
tab: if *page == "people" {
ProfileTab::People
} else {
repos()
},
},
[login] if !RESERVED.contains(login) && valid_login(login) => Route::User {
login: (*login).to_owned(),
tab: match tab.as_deref() {
None | Some("overview") => ProfileTab::Overview,
Some("repositories") => repos(),
Some("stars") => ProfileTab::Stars,
Some("followers") => ProfileTab::Followers,
Some("following") => ProfileTab::Following,
Some(_) => return None,
},
},
[first, ..] if RESERVED.contains(first) => return None,
[o, r, rest @ ..] => return repo_page(RepoId::parse(&format!("{o}/{r}"))?, rest, parsed),
_ => return None,
};
Some(Target::Page(route))
}
fn param(url: &url::Url, name: &str) -> Option<String> {
url.query_pairs()
.find(|(k, _)| k == name)
.map(|(_, v)| v.into_owned())
}
fn lined(url: String, lines: Option<(u32, u32)>) -> String {
match lines {
None => url,
Some((a, b)) if a == b => format!("{url}#L{a}"),
Some((a, b)) => format!("{url}#L{a}-L{b}"),
}
}
fn rev_page(repo: RepoId, view: &str, rest: &[&str], fragment: Option<&str>) -> Option<Route> {
let view = match view {
"tree" => RevView::Tree,
"blob" => RevView::Blob,
"blame" => RevView::Blame,
"commits" => RevView::Commits,
_ => return None,
};
let lines = fragment.and_then(line_range);
match rest {
[] if view == RevView::Commits => view.at(repo, "HEAD".into(), String::new(), lines),
[rev, ..] if !valid_rev(rev) => None,
[rev, path @ ..]
if path.is_empty()
|| rev.contains('/')
|| *rev == "HEAD"
|| (rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit())) =>
{
view.at(repo, (*rev).to_owned(), path.join("/"), lines)
}
[_, ..] => Some(Route::Unsplit {
repo,
view,
spot: rest.join("/"),
lines,
}),
[] => None,
}
}
fn repo_page(repo: RepoId, rest: &[&str], url: &url::Url) -> Option<Target> {
let param = |name: &str| param(url, name);
let fragment = url.fragment();
let route = match rest {
[] => Route::Repo(repo),
[view @ ("tree" | "blob" | "blame" | "commits"), rest @ ..] => {
rev_page(repo, view, rest, fragment)?
}
[list @ ("issues" | "pulls")] => {
let query = param("q").map_or_else(|| OPEN.to_owned(), |q| strip_is(&q));
if *list == "issues" {
Route::Issues { repo, query }
} else {
Route::Pulls { repo, query }
}
}
["labels", label] if label.contains(char::is_control) => return None,
["labels", label] => {
let label = label.split_whitespace().collect::<Vec<_>>().join(" ");
let label = if label.contains(' ') {
format!("\"{label}\"")
} else {
label
};
Route::Issues {
repo,
query: format!("{OPEN} label:{label}"),
}
}
["actions"] => Route::Actions(repo),
["stargazers"] => Route::Stargazers(repo),
["watchers"] => Route::Watchers(repo),
["forks"] | ["network", "members"] => Route::Forks(repo),
["releases", "tag", tag @ ..] if !tag.is_empty() => Route::Release {
repo,
tag: tag.join("/"),
},
["network"]
| ["releases", "download", ..]
| ["wiki", .., "_new" | "_edit" | "_compare"]
| ["security", "advisories", "new"] => return None,
["wiki", special, ..] if special.starts_with('_') && *special != "_pages" => return None,
["releases", ..] => Route::Releases(repo),
["tags"] => Route::Tags(repo),
["milestones", ..] => Route::Milestones {
repo,
closed: param("state").as_deref() == Some("closed"),
},
["milestone", n] => Route::Milestone {
repo,
number: n.parse().ok()?,
},
["compare", .., last] if last.ends_with(".patch") || last.ends_with(".diff") => {
return None;
}
["compare", rest @ ..] => {
let spec = rest.join("/");
if spec.is_empty() {
return None;
}
if fragment == Some("files")
&& !spec.contains("...")
&& let Some((from, to)) = spec.split_once("..")
&& let Ok(from) = Oid::parse(from)
&& let Ok(to) = Oid::parse(to)
{
return Some(Target::Files(DiffOf::Range(repo, from, to)));
}
Route::Compare { repo, spec }
}
["security"] | ["security", "advisories"] => Route::Advisories(Some(repo)),
["security", "advisories", ghsa] if valid_ghsa(ghsa) => Route::Advisory {
repo: Some(repo),
ghsa: (*ghsa).to_owned(),
},
["wiki", rest @ ..] if !rest.iter().any(|p| p.contains(char::is_control)) => Route::Wiki {
repo,
page: rest.first().map(|p| (*p).to_owned()),
},
["deployments", rest @ ..] => {
let environment = match rest {
[] => None,
["activity_log"] => param("environments_filter"),
[env] if !env.contains(char::is_control) => Some((*env).to_owned()),
_ => return None,
};
Route::Deployments { repo, environment }
}
["branches", ..] => Route::Branches(repo),
["discussions", rest @ ..] => discussions(DiscussionsOf::Repo(repo), rest)?,
["actions", "runs", run, rest @ ..] => {
let run = run.parse().ok()?;
match rest {
[] | ["workflow"] => Route::WorkflowRun {
repo,
run,
attempt: None,
},
["attempts", n] => Route::WorkflowRun {
repo,
run,
attempt: Some(n.parse().ok()?),
},
["job", job] => Route::Job {
repo,
run: Some(run),
job: job.parse().ok()?,
step: fragment.and_then(step_line),
query: param("q").unwrap_or_default(),
},
_ => return None,
}
}
["runs", job] | ["runs", _, "jobs", job] => Route::Job {
repo,
run: None,
job: job.parse().ok()?,
step: fragment.and_then(step_line),
query: param("q").unwrap_or_default(),
},
["actions", "workflows", file]
if !file.contains(|c: char| c.is_control() || c.is_whitespace()) =>
{
Route::Workflow {
repo,
file: (*file).to_owned(),
}
}
["commit", rev, ..] | ["git", "commit", rev] if !valid_rev(rev) => {
return None;
}
["commit", _, "checks", job, ..] => Route::Job {
repo,
run: None,
job: job.parse().ok()?,
step: None,
query: String::new(),
},
["commit", sha, "checks"] => Route::CommitChecks {
repo,
oid: (*sha).to_owned(),
},
["git", "commit", sha] => Route::Commit {
repo,
oid: (*sha).to_owned(),
},
["commit", sha] if sha.ends_with(".patch") => return None,
["pull", n] if n.ends_with(".diff") => {
let number = n.trim_end_matches(".diff").parse().ok()?;
return Some(Target::Files(DiffOf::Pr(PrRef { repo, number })));
}
["commit", rev] => {
let (rev, diff) = rev
.strip_suffix(".diff")
.map_or((*rev, false), |r| (r, true));
if (diff || fragment.is_some_and(|f| f == "files" || f.starts_with("diff-")))
&& let Ok(oid) = Oid::parse(rev)
{
return Some(Target::Files(DiffOf::Commit(repo, oid)));
}
Route::Commit {
repo,
oid: rev.to_owned(),
}
}
["issues", n] => Route::Issue {
repo,
number: n.parse().ok()?,
},
["pull", n, rest @ ..] => {
let pr = PrRef {
repo,
number: n.parse().ok()?,
};
let review_comment = fragment.is_some_and(|f| {
f.strip_prefix("discussion_r")
.or_else(|| f.strip_prefix('r'))
.is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()))
});
match rest {
[] if review_comment => return Some(Target::Files(DiffOf::Pr(pr))),
[] => Route::pr(pr),
["commits" | "changes", sha] if valid_rev(sha) => Route::Commit {
repo: pr.repo,
oid: (*sha).to_owned(),
},
["files" | "changes", ..] => return Some(Target::Files(DiffOf::Pr(pr))),
["commits", ..] => Route::Pr {
pr,
tab: PrTab::Commits,
},
["checks", ..] => Route::Pr {
pr,
tab: PrTab::Checks,
},
_ => return None,
}
}
_ => return None,
};
Some(Target::Page(route))
}
const GIST: &str = "https://gist.github.com";
const GIST_PAGES: &[&str] = &[
"discover", "starred", "search", "auth", "login", "join", "mine",
];
fn is_gist_id(s: &str) -> bool {
s.bytes().all(|b| b.is_ascii_digit())
|| (matches!(s.len(), 20 | 32) && s.bytes().all(|b| b.is_ascii_hexdigit()))
}
fn is_lone_gist_id(s: &str) -> bool {
is_gist_id(s) && !(s.len() < 5 && s.bytes().all(|b| b.is_ascii_digit()))
}
fn gist(parsed: &url::Url) -> Option<Route> {
let segments: Vec<&str> = parsed.path_segments()?.filter(|s| !s.is_empty()).collect();
Some(match segments.as_slice() {
[first, ..] if GIST_PAGES.contains(first) => return None,
[id] if is_lone_gist_id(id) => Route::Gist {
owner: None,
id: (*id).to_owned(),
},
[login] if valid_login(login) => Route::Gists((*login).to_owned()),
[owner, id] if is_gist_id(id) && valid_login(owner) => Route::Gist {
owner: Some((*owner).to_owned()),
id: (*id).to_owned(),
},
[owner, id, "revisions" | "forks" | "stargazers"]
if is_gist_id(id) && valid_login(owner) =>
{
Route::Gist {
owner: Some((*owner).to_owned()),
id: (*id).to_owned(),
}
}
_ => return None,
})
}
fn discussions(of: DiscussionsOf, rest: &[&str]) -> Option<Route> {
Some(match rest {
[] => Route::Discussions { of, category: None },
["categories", slug] if valid_slug(slug) => Route::Discussions {
of,
category: Some((*slug).to_owned()),
},
[n] => Route::Discussion {
of,
number: n.parse().ok()?,
},
_ => return None,
})
}
fn discussions_url(of: &DiscussionsOf) -> String {
match of {
DiscussionsOf::Repo(repo) => format!("{}/discussions", links::repo(repo)),
DiscussionsOf::Org(org) => format!("{}/orgs/{org}/discussions", links::BASE),
}
}
fn of_title(of: &DiscussionsOf) -> String {
match of {
DiscussionsOf::Repo(repo) => repo.to_string(),
DiscussionsOf::Org(org) => format!("@{org}"),
}
}
fn step_line(fragment: &str) -> Option<(u32, u32)> {
let mut parts = fragment.strip_prefix("step:")?.split(':');
let step = parts.next()?.parse().ok()?;
let line = parts.next().and_then(|l| l.parse().ok()).unwrap_or(1);
Some((step, line))
}
fn line_range(fragment: &str) -> Option<(u32, u32)> {
let line = |s: &str| s.strip_prefix('L')?.parse::<u32>().ok().filter(|&n| n > 0);
let (a, b) = match fragment.split_once('-') {
Some((a, b)) => (line(a)?, line(b)?),
None => (line(fragment)?, line(fragment)?),
};
Some((a.min(b), a.max(b)))
}
const RESERVED: &[&str] = &[
"about",
"account",
"advisories",
"apps",
"codespaces",
"collections",
"contact",
"copilot",
"customer-stories",
"enterprise",
"enterprises",
"events",
"explore",
"features",
"github-copilot",
"issues",
"join",
"licenses",
"login",
"logout",
"marketplace",
"mcp",
"new",
"notifications",
"open-source",
"organizations",
"partners",
"pricing",
"pulls",
"readme",
"repos",
"resources",
"security",
"sessions",
"settings",
"signup",
"site",
"solutions",
"sponsors",
"stars",
"team",
"topics",
"trending",
"trust-center",
"user-attachments",
"users",
"why-github",
];
fn decode(segment: &str) -> String {
let mut out = Vec::with_capacity(segment.len());
let mut rest = segment.as_bytes();
while let Some((&b, tail)) = rest.split_first() {
let escaped = match tail {
[hi, lo, ..] if b == b'%' => std::str::from_utf8(&[*hi, *lo])
.ok()
.and_then(|h| u8::from_str_radix(h, 16).ok()),
_ => None,
};
match (escaped, tail.get(2..)) {
(Some(byte), Some(after)) => {
out.push(byte);
rest = after;
}
_ => {
out.push(b);
rest = tail;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn strip_is(query: &str) -> String {
let words: Vec<&str> = query
.split_whitespace()
.filter(|w| !matches!(*w, "is:issue" | "is:pr"))
.collect();
if words.is_empty() {
OPEN.to_owned()
} else {
words.join(" ")
}
}
pub fn parse_input(input: &str, context: Option<&RepoId>) -> Option<Dest> {
let input = input.trim();
if input.contains("://") || input.starts_with("github.com/") {
let dest = Dest::from_url(input);
return (!matches!(dest.target, Target::External(_))).then_some(dest);
}
if let Some(login) = input.strip_prefix('@') {
return valid_login(login).then(|| Target::Page(Route::user(login)).into());
}
let issue = |repo: RepoId, n: &str| {
let number = n.trim_start_matches('#').parse().ok()?;
Some(Target::Page(Route::Issue { repo, number }).into())
};
if let Some((repo, n)) = input.split_once('#')
&& !repo.is_empty()
{
return issue(RepoId::parse(repo)?, n);
}
if input
.trim_start_matches('#')
.chars()
.all(|c| c.is_ascii_digit())
{
return issue(context?.clone(), input);
}
if input.contains('/') {
return RepoId::parse(input).map(|r| Target::Page(Route::Repo(r)).into());
}
None
}
fn valid_login(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
fn valid_rev(s: &str) -> bool {
s.split('/')
.all(|c| !c.is_empty() && !c.starts_with('.') && !c.ends_with(".lock"))
&& !s.contains("..")
&& !s.contains("@{")
&& !s.ends_with('.')
&& !s
.chars()
.any(|c| c.is_control() || c.is_whitespace() || "~^:?*[\\".contains(c))
}
fn valid_ghsa(s: &str) -> bool {
s.strip_prefix("GHSA-").is_some_and(|rest| {
!rest.is_empty() && rest.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
})
}
fn valid_slug(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
#[test]
fn impossible_names_open_the_browser() {
for url in [
"https://github.com/https:%00%00/",
"https://github.com/a%20b",
"https://github.com/stars/a:b",
"https://github.com/orgs/a%2Fb/teams",
"https://github.com/orgs/a/teams/b%20c",
"https://gist.github.com/a:b",
"https://github.com/o/r/commit/o%20[[x]]%20",
"https://github.com/o/r/commit/a..b",
"https://github.com/o/r/labels/a%0Bb",
] {
assert!(
matches!(Dest::from_url(url).target, Target::External(_)),
"{url}"
);
}
assert_eq!(page("https://github.com/octo-cat"), Route::user("octo-cat"));
assert!(matches!(
page("https://github.com/o/r/commit/main"),
Route::Commit { .. }
));
}
#[test]
fn names_in_urls_are_checked() {
let sha = "0123456789abcdef0123456789abcdef01234567";
for (url, opens) in [
(
"https://github.com/advisories/GHSA-abcd-efgh-2345".to_owned(),
true,
),
("https://github.com/advisories/GHSB-1".to_owned(), false),
("https://github.com/advisories/GHSA-".to_owned(), false),
(
"https://github.com/o/r/security/advisories/GHSA-a%20b".to_owned(),
false,
),
(
"https://github.com/o/r/blame/main/src/a.rs".to_owned(),
true,
),
(
"https://github.com/o/r/blame/a..b/src/a.rs".to_owned(),
false,
),
("https://github.com/o/r/blame/main".to_owned(), false),
("https://github.com/o/r/wiki/Some%20Page".to_owned(), true),
("https://github.com/o/r/wiki/a%01b".to_owned(), false),
(
"https://github.com/o/r/actions/workflows/ci.yml".to_owned(),
true,
),
(
"https://github.com/o/r/actions/workflows/c%20i.yml".to_owned(),
false,
),
(
"https://github.com/o/r/deployments/staging%20eu".to_owned(),
true,
),
("https://github.com/o/r/deployments/a%01".to_owned(), false),
("https://github.com/o/r/commits/main/src".to_owned(), true),
("https://github.com/o/r/commits/a@{1}".to_owned(), false),
(format!("https://github.com/o/r/pull/1/commits/{sha}"), true),
(
"https://github.com/o/r/discussions/categories/q-a".to_owned(),
true,
),
(
"https://github.com/o/r/discussions/categories/q%20a".to_owned(),
false,
),
(
"https://gist.github.com/octocat/0123456789abcdef0123".to_owned(),
true,
),
(
"https://gist.github.com/octo.cat/0123456789abcdef0123".to_owned(),
false,
),
(
"https://gist.github.com/octocat/0123456789abcdef0123/revisions".to_owned(),
true,
),
(
"https://gist.github.com/oc%20t/0123456789abcdef0123/revisions".to_owned(),
false,
),
] {
let target = Dest::from_url(&url).target;
assert_eq!(
matches!(target, Target::Page(_)),
opens,
"{url}: {target:?}"
);
}
assert!(matches!(
Dest::from_url("https://github.com/o/r/pull/1/commits/a%20b").target,
Target::Page(Route::Pr {
tab: PrTab::Commits,
..
})
));
assert!(matches!(
Dest::from_url(&format!("https://github.com/o/r/commit/{sha}#diff-1")).target,
Target::Files(_)
));
assert!(matches!(
Dest::from_url("https://github.com/o/r/commit/0123456#diff-1").target,
Target::Page(Route::Commit { .. })
));
assert!(!valid_rev("a b") && !valid_rev("a~1") && !valid_rev("a.") && valid_rev("v1.2/x"));
for view in ["tree", "blob", "blame", "commits"] {
for rev in ["a..b", "a~1", "%2E%2Fx", "a%2F%2Fb", "%2F", "x.lock%2Fy"] {
let url = format!("https://github.com/o/r/{view}/{rev}/src/lib.rs");
assert!(
matches!(Dest::from_url(&url).target, Target::External(_)),
"{url}"
);
}
}
assert!(valid_ghsa("GHSA-2345") && !valid_ghsa("ghsa-1") && !valid_ghsa("GHSA-1 2"));
}
fn reopened(route: &Route) -> Target {
match Dest::from_url(&route.url()).target {
Target::Page(r) => Target::Page(r.split(route.rev()).unwrap_or(r)),
target => target,
}
}
fn page(url: &str) -> Route {
match Dest::from_url(url).target {
Target::Page(route) => route,
other => panic!("{url}: {other:?}"),
}
}
#[test]
fn parses_github_urls() {
let repo = RepoId::new("o", "r");
assert_eq!(page("https://github.com/"), Route::Home);
assert_eq!(page("https://github.com/octocat"), Route::user("octocat"));
assert_eq!(page("https://github.com/o/r"), Route::Repo(repo.clone()));
assert_eq!(
page("https://github.com/o/r/tree/main/src/ui").split(Some("main")),
Some(Route::Tree {
repo: repo.clone(),
rev: "main".into(),
path: "src/ui".into()
})
);
assert_eq!(
page("https://github.com/o/r/blob/v1.0/a%20b.md#readme").split(None),
Some(Route::blob(repo.clone(), "v1.0".into(), "a b.md".into()))
);
for (fragment, lines) in [("L7", (7, 7)), ("L20-L10", (10, 20)), ("L0", (0, 0))] {
let url = format!("https://github.com/o/r/blob/main/a.rs#{fragment}");
let expected = (lines.0 > 0).then_some(lines);
assert!(
matches!(page(&url).split(None), Some(Route::Blob { lines, .. }) if lines == expected),
"{fragment}"
);
}
assert_eq!(
page("https://github.com/o/r/issues?q=is%3Aissue+is%3Aclosed"),
Route::Issues {
repo: repo.clone(),
query: "is:closed".into()
}
);
assert_eq!(
page("https://github.com/o/r/pulls"),
Route::Pulls {
repo: repo.clone(),
query: OPEN.into()
}
);
assert_eq!(
page("https://github.com/o/r/issues/5"),
Route::Issue {
repo: repo.clone(),
number: 5
}
);
let pr = PrRef { repo, number: 7 };
assert_eq!(
page("https://github.com/o/r/pull/7/commits"),
Route::Pr {
pr: pr.clone(),
tab: PrTab::Commits
}
);
assert_eq!(
Dest::from_url("https://github.com/o/r/pull/7/files").target,
Target::Files(DiffOf::Pr(pr.clone()))
);
let sha = "0123456789abcdef0123456789abcdef01234567";
let commit = |oid: &str| Route::Commit {
repo: pr.repo.clone(),
oid: oid.into(),
};
assert_eq!(
page(&format!("https://github.com/o/r/commit/{sha}")),
commit(sha)
);
let commits = |rev: &str, path: &str| Route::Commits {
repo: pr.repo.clone(),
rev: rev.into(),
path: path.into(),
};
assert_eq!(
page("https://github.com/o/r/commits/main/src/ui").split(None),
Some(commits("main", "src/ui"))
);
assert_eq!(page("https://github.com/o/r/commits"), commits("HEAD", ""));
assert_eq!(
page("https://github.com/o/r/actions"),
Route::Actions(pr.repo.clone())
);
assert_eq!(
page("https://github.com/o/r/stargazers"),
Route::Stargazers(pr.repo.clone())
);
assert_eq!(
page("https://github.com/o/r/network/members"),
Route::Forks(pr.repo.clone())
);
assert_eq!(
page("https://github.com/o/r/releases/tag/v1.0/rc"),
Route::Release {
repo: pr.repo.clone(),
tag: "v1.0/rc".into()
}
);
assert_eq!(
page("https://github.com/o/r/releases/latest"),
Route::Releases(pr.repo.clone())
);
assert_eq!(
page("https://github.com/o/r/tags"),
Route::Tags(pr.repo.clone())
);
assert!(matches!(
Dest::from_url("https://github.com/o/r/releases/download/v1/x.tgz").target,
Target::External(_)
));
assert_eq!(
page("https://github.com/o/r/pull/7/checks"),
Route::Pr {
pr: pr.clone(),
tab: PrTab::Checks
}
);
assert_eq!(
page("https://github.com/o/r/commit/0123abc"),
commit("0123abc")
);
assert_eq!(
page(&format!("https://github.com/o/r/pull/7/commits/{sha}")),
commit(sha)
);
assert_eq!(
Dest::from_url(&format!("https://github.com/o/r/commit/{sha}#diff-abc")).target,
Target::Files(DiffOf::Commit(pr.repo.clone(), Oid::parse(sha).unwrap()))
);
assert_eq!(
page("https://github.com/search?q=tui+lang%3Arust&type=repositories"),
Route::Search {
kind: SearchKind::Repos,
query: "tui lang:rust".into()
}
);
assert_eq!(
page("https://github.com/o/r/labels/good%20first%20issue"),
Route::Issues {
repo: RepoId::new("o", "r"),
query: "is:open label:\"good first issue\"".into()
}
);
assert_eq!(
page("https://github.com/o/r/labels/bug"),
Route::Issues {
repo: RepoId::new("o", "r"),
query: "is:open label:bug".into()
}
);
assert_eq!(
page("https://github.com/octocat?tab=repositories&sort=stargazers"),
Route::User {
login: "octocat".into(),
tab: ProfileTab::Repositories(RepoSort::Stars)
}
);
assert_eq!(
page("https://github.com/octocat?tab=followers"),
Route::User {
login: "octocat".into(),
tab: ProfileTab::Followers
}
);
assert_eq!(
page("https://github.com/orgs/rust-lang/people"),
Route::User {
login: "rust-lang".into(),
tab: ProfileTab::People
}
);
assert_eq!(
page("https://github.com/orgs/rust-lang/repositories"),
Route::User {
login: "rust-lang".into(),
tab: ProfileTab::Repositories(RepoSort::Updated)
}
);
assert_eq!(
page("https://github.com/o/r/actions/runs/9"),
Route::WorkflowRun {
repo: pr.repo.clone(),
run: 9,
attempt: None
}
);
assert_eq!(
page("https://github.com/o/r/milestone/3"),
Route::Milestone {
repo: pr.repo.clone(),
number: 3
}
);
assert_eq!(
page("https://github.com/o/r/compare/v1...fork:feature/x"),
Route::Compare {
repo: pr.repo.clone(),
spec: "v1...fork:feature/x".into()
}
);
let other = "f".repeat(40);
assert_eq!(
Dest::from_url(&format!(
"https://github.com/o/r/compare/{sha}..{other}#files"
))
.target,
Target::Files(DiffOf::Range(
pr.repo.clone(),
Oid::parse(sha).unwrap(),
Oid::parse(&other).unwrap()
))
);
assert_eq!(
page(&format!(
"https://github.com/o/r/compare/{sha}...{other}#files"
)),
Route::Compare {
repo: pr.repo.clone(),
spec: format!("{sha}...{other}")
}
);
let (from, to) = (Oid::parse(sha).unwrap(), Oid::parse(&other).unwrap());
for of in [
DiffOf::Pr(pr.clone()),
DiffOf::Commit(pr.repo.clone(), from.clone()),
DiffOf::Range(pr.repo.clone(), from, to),
] {
assert_eq!(Dest::from_url(&of.url()).target, Target::Files(of.clone()));
}
assert_eq!(
page("https://github.com/search?type=topics"),
Route::Search {
kind: SearchKind::Repos,
query: String::new()
}
);
assert_eq!(
page("https://gist.github.com/2000"),
Route::Gists("2000".into())
);
assert_eq!(
page("https://gist.github.com/1162032"),
Route::Gist {
owner: None,
id: "1162032".into()
}
);
let blame = Route::Blame {
repo: RepoId::new("blob", "blob"),
rev: "main".into(),
path: "f".into(),
lines: None,
};
assert_eq!(reopened(&blame), Target::Page(blame));
assert!(matches!(
Dest::from_url("https://github.com/settings/tokens").target,
Target::External(_)
));
assert!(matches!(
Dest::from_url("https://example.com/o/r").target,
Target::External(_)
));
}
pub(crate) const REASONS: [&str; 6] = [
"download",
"session",
"scope",
"not-github",
"no-api",
"rare",
];
pub(crate) fn corpus() -> Vec<(&'static str, &'static str, Option<&'static str>)> {
include_str!("../tests/github_urls.txt")
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|line| {
let mut words = line.split_whitespace();
let url = words.next().unwrap();
let expect = words
.next()
.unwrap_or_else(|| panic!("no expectation: {line}"));
(url, expect, words.next())
})
.collect()
}
pub(crate) fn variant(route: &Route) -> String {
let route = route.split(None).unwrap_or_else(|| route.clone());
format!("{route:?}")
.split(|c: char| !c.is_alphanumeric())
.next()
.unwrap_or_default()
.to_owned()
}
pub(crate) fn as_expected(target: &Target, expect: &str, reason: Option<&str>) -> bool {
match (expect, target) {
("external", Target::External(_)) => reason.is_some_and(|r| REASONS.contains(&r)),
("todo", Target::External(_)) | ("files", Target::Files(_)) => true,
(variant_name, Target::Page(route)) => variant(route) == variant_name,
_ => false,
}
}
#[test]
fn every_github_link_does_what_the_corpus_says() {
let mut wrong = Vec::new();
for (url, expect, reason) in corpus() {
let target = Dest::from_url(url).target;
if !as_expected(&target, expect, reason) {
wrong.push(format!(
"{url}\n expected {expect} {}, got {target:?}",
reason.unwrap_or("")
));
}
}
assert!(
wrong.is_empty(),
"{} wrong:\n{}",
wrong.len(),
wrong.join("\n")
);
}
#[test]
fn no_link_waits_for_a_page() {
let todo: Vec<&str> = corpus()
.iter()
.filter(|(_, expect, _)| *expect == "todo")
.map(|(url, _, _)| *url)
.collect();
assert!(
todo.is_empty(),
"pages still to build:\n{}",
todo.join("\n")
);
}
#[test]
fn routes_round_trip_through_urls() {
let repo = RepoId::new("o", "r");
for route in [
Route::Home,
Route::Repo(repo.clone()),
Route::Tree {
repo: repo.clone(),
rev: "main".into(),
path: "src".into(),
},
Route::Blob {
repo: repo.clone(),
rev: "main".into(),
path: "src/lib.rs".into(),
lines: Some((3, 9)),
},
Route::Issues {
repo: repo.clone(),
query: "is:closed label:bug".into(),
},
Route::Pulls {
repo: repo.clone(),
query: OPEN.into(),
},
Route::Commit {
repo: repo.clone(),
oid: "abc1234".into(),
},
Route::Issue { repo, number: 3 },
Route::user("octocat"),
Route::Search {
kind: SearchKind::Users,
query: "linus".into(),
},
Route::Search {
kind: SearchKind::Pulls,
query: "author:linus".into(),
},
Route::User {
login: "octocat".into(),
tab: ProfileTab::Stars,
},
] {
assert_eq!(reopened(&route), Target::Page(route));
}
}
#[test]
fn refs_with_slashes_round_trip_through_urls() {
let repo = RepoId::new("o", "r");
let mut wrong = Vec::new();
for rev in ["feature/x", "dependabot/cargo/serde-1.0", "release/1.0"] {
for route in [
Route::Tree {
repo: repo.clone(),
rev: rev.into(),
path: String::new(),
},
Route::Tree {
repo: repo.clone(),
rev: rev.into(),
path: "src".into(),
},
Route::blob(repo.clone(), rev.into(), "src/lib.rs".into()),
Route::Blame {
repo: repo.clone(),
rev: rev.into(),
path: "src/lib.rs".into(),
lines: None,
},
Route::Commits {
repo: repo.clone(),
rev: rev.into(),
path: String::new(),
},
Route::Commits {
repo: repo.clone(),
rev: rev.into(),
path: "src".into(),
},
] {
let url = route.url();
let back = Dest::from_url(&url).target;
if back != Target::Page(route.clone()) {
wrong.push(format!(
"{url}\n expected {route:?}\n got {back:?}"
));
}
}
}
assert!(
wrong.is_empty(),
"{} URLs lose their ref:\n{}",
wrong.len(),
wrong.join("\n")
);
}
#[test]
fn commits_named_by_slashed_refs_round_trip() {
let (repo, oid) = (RepoId::new("o", "r"), "feature/x".to_owned());
for route in [
Route::Commit {
repo: repo.clone(),
oid: oid.clone(),
},
Route::CommitChecks { repo, oid },
] {
let url = route.url();
assert_eq!(Dest::from_url(&url).target, Target::Page(route), "{url}");
}
}
#[test]
fn plain_urls_wait_for_where_the_ref_ends() {
let repo = RepoId::new("o", "r");
let tree = |rev: &str, path: &str| Route::Tree {
repo: repo.clone(),
rev: rev.into(),
path: path.into(),
};
let split = |url: &str, rev: Option<&str>| {
let route = page(&format!("https://github.com/o/r/{url}"));
assert!(matches!(route, Route::Unsplit { .. }), "{url}: {route:?}");
route.split(rev)
};
assert_eq!(
split("tree/feature/x/src", Some("feature/x")),
Some(tree("feature/x", "src"))
);
assert_eq!(
split("tree/feature/x/src", None),
Some(tree("feature", "x/src"))
);
assert_eq!(
split("tree/release/1.0", Some("release/1.0")),
Some(tree("release/1.0", ""))
);
assert_eq!(
split("blob/deep/a/b/c/src/lib.rs#L3", Some("deep/a/b/c")),
Some(Route::Blob {
repo: repo.clone(),
rev: "deep/a/b/c".into(),
path: "src/lib.rs".into(),
lines: Some((3, 3)),
})
);
assert_eq!(
split("tree/feature/xy", Some("feature/x")),
Some(tree("feature", "xy"))
);
assert!(matches!(
split("blame/feature/x", Some("feature/x")),
Some(Route::Blame { rev, path, .. }) if rev == "feature" && path == "x"
));
let sha = "0123456789abcdef0123456789abcdef01234567";
for (url, rev, path) in [
("tree/feature%2Fx/src".to_owned(), "feature/x", "src"),
("tree/HEAD/src".to_owned(), "HEAD", "src"),
(format!("tree/{sha}/src"), sha, "src"),
("tree/feature".to_owned(), "feature", ""),
] {
assert_eq!(
page(&format!("https://github.com/o/r/{url}")),
tree(rev, path),
"{url}"
);
}
}
#[test]
fn branch_links_open_the_branch() {
let repo = RepoId::new("o", "r");
let url = crate::ui::pages::url::tree(&repo, "dependabot/cargo/serde-1.0", "");
assert_eq!(
Dest::from_url(&url).target,
Target::Page(Route::Tree {
repo,
rev: "dependabot/cargo/serde-1.0".into(),
path: String::new(),
}),
"{url}"
);
}
#[test]
fn palette_input() {
let repo = RepoId::new("o", "r");
let target = |input, context| parse_input(input, context).map(|d| d.target);
assert_eq!(
target("a/b", None),
Some(Target::Page(Route::Repo(RepoId::new("a", "b"))))
);
assert_eq!(
target("@octocat", None),
Some(Target::Page(Route::user("octocat")))
);
assert_eq!(
target("#12", Some(&repo)),
Some(Target::Page(Route::Issue {
repo: repo.clone(),
number: 12
}))
);
assert_eq!(target("12", None), None);
assert_eq!(
target("a/b#3", None),
Some(Target::Page(Route::Issue {
repo: RepoId::new("a", "b"),
number: 3
}))
);
assert_eq!(parse_input("ratatui", None), None);
}
#[test]
fn list_queries_scope_to_the_repo() {
let route = Route::Pulls {
repo: RepoId::new("o", "r"),
query: "is:open author:me".into(),
};
assert_eq!(
route.search(),
Some((
SearchKind::Issues,
"repo:o/r is:pr is:open author:me sort:created-desc".into()
))
);
}
mod props {
use super::*;
use proptest::prelude::*;
fn segment() -> impl Strategy<Value = String> {
"[A-Za-z0-9][A-Za-z0-9_.-]{0,15}".prop_filter("not . or .. or .git", |s| {
s != "." && s != ".." && !s.ends_with(".git")
})
}
fn login() -> impl Strategy<Value = String> {
"[A-Za-z0-9][A-Za-z0-9-]{0,15}"
}
fn repo() -> impl Strategy<Value = RepoId> {
(segment(), segment()).prop_map(|(o, r)| RepoId::new(o, r))
}
fn path() -> impl Strategy<Value = String> {
let part =
"[^/\\x00-\\x1f]{1,12}".prop_filter("not . or ..", |p| p != "." && p != "..");
prop::collection::vec(part, 0..4).prop_map(|parts| parts.join("/"))
}
fn query() -> impl Strategy<Value = String> {
prop::collection::vec("[a-z0-9:#&+%\"]{1,8}", 1..4)
.prop_map(|words| words.join(" "))
.prop_filter("no is: words", |q| {
!q.split(' ').any(|w| w.starts_with("is:"))
})
}
fn discussions_of() -> impl Strategy<Value = DiscussionsOf> {
prop_oneof![
repo().prop_map(DiscussionsOf::Repo),
login().prop_map(DiscussionsOf::Org),
]
}
fn route() -> impl Strategy<Value = Route> {
let rev =
prop::collection::vec("[A-Za-z0-9_#%+@&;é-][A-Za-z0-9._#%+@&;é-]{0,7}", 1..=3)
.prop_map(|s| s.join("/"))
.prop_filter("valid ref name", |r| {
!r.contains("..")
&& !r.ends_with('.')
&& !r.split('/').any(|c| c.ends_with(".lock"))
});
prop_oneof![
Just(Route::Home),
repo().prop_map(Route::Actions),
repo().prop_map(Route::Stargazers),
repo().prop_map(Route::Watchers),
repo().prop_map(Route::Forks),
repo().prop_map(Route::Releases),
repo().prop_map(Route::Tags),
repo().prop_map(Route::Branches),
(repo(), 1..u64::MAX, prop::option::of(1..100u64))
.prop_map(|(repo, run, attempt)| Route::WorkflowRun { repo, run, attempt }),
(
repo(),
prop::option::of(1..u64::MAX),
1..u64::MAX,
prop::option::of((1..100u32, 1..10_000u32)),
prop_oneof![Just(String::new()), query()],
)
.prop_map(|(repo, run, job, step, query)| Route::Job {
repo,
run,
job,
step,
query
}),
(repo(), "[A-Za-z0-9_-][A-Za-z0-9_.-]{0,11}\\.ya?ml")
.prop_map(|(repo, file)| Route::Workflow { repo, file }),
(repo(), "[0-9a-f]{40}").prop_map(|(repo, oid)| Route::CommitChecks { repo, oid }),
(
discussions_of(),
prop::option::of("[a-z0-9][a-z0-9-]{0,11}")
)
.prop_map(|(of, category)| Route::Discussions { of, category }),
(discussions_of(), 1..u64::MAX)
.prop_map(|(of, number)| Route::Discussion { of, number }),
(repo(), prop::option::of("[A-Za-z0-9][A-Za-z0-9 _-]{0,15}"))
.prop_filter("not a writing page", |(_, p)| {
!matches!(p.as_deref(), Some("_new" | "_edit" | "_compare"))
})
.prop_map(|(repo, page)| Route::Wiki { repo, page }),
prop::option::of(repo()).prop_map(Route::Advisories),
(prop::option::of(repo()), "GHSA(-[2-9a-z]{4}){3}")
.prop_map(|(repo, ghsa)| Route::Advisory { repo, ghsa }),
login().prop_map(Route::Teams),
(login(), segment())
.prop_filter("not the new-team form", |(_, slug)| slug != "new")
.prop_map(|(org, slug)| Route::Team { org, slug }),
(prop::option::of(login()), "[0-9a-f]{20}|[0-9]{1,8}")
.prop_filter("an owner isn't a gist ID or a gist page", |(o, _)| {
o.as_deref()
.is_none_or(|o| !is_gist_id(o) && !GIST_PAGES.contains(&o))
})
.prop_filter("a lone ID is a gist's", |(o, id)| {
o.is_some() || is_lone_gist_id(id)
})
.prop_map(|(owner, id)| Route::Gist { owner, id }),
login()
.prop_filter("not a gist ID or a gist page", |l| {
!is_lone_gist_id(l) && !GIST_PAGES.contains(&l.as_str())
})
.prop_map(Route::Gists),
(
repo(),
rev.clone(),
path(),
prop::option::of((1..1000u32, 1..1000u32))
)
.prop_filter("a file has a path", |(_, _, p, _)| !p.is_empty())
.prop_map(|(repo, rev, path, lines)| Route::Blame {
repo,
rev,
path,
lines: lines.map(|(a, b)| (a.min(b), a.max(b))),
}),
(repo(), rev.clone(), rev.clone(), any::<bool>()).prop_map(|(repo, a, b, two)| {
Route::Compare {
repo,
spec: format!("{a}{}{b}", if two { ".." } else { "..." }),
}
}),
(repo(), prop::option::of("[A-Za-z0-9 _-]{1,12}"))
.prop_filter("not the log", |(_, e)| e.as_deref() != Some("activity_log"))
.prop_map(|(repo, environment)| Route::Deployments { repo, environment }),
(repo(), any::<bool>())
.prop_map(|(repo, closed)| Route::Milestones { repo, closed }),
(repo(), 1..u64::MAX).prop_map(|(repo, number)| Route::Milestone { repo, number }),
(repo(), "[A-Za-z0-9._-]{1,12}")
.prop_filter("not . or ..", |(_, t)| t != "." && t != "..")
.prop_map(|(repo, tag)| Route::Release { repo, tag }),
repo().prop_map(Route::Repo),
(repo(), rev.clone(), path()).prop_map(|(repo, rev, path)| Route::Tree {
repo,
rev,
path
}),
(
repo(),
rev.clone(),
path(),
prop::option::of((1..1000u32, 1..1000u32))
)
.prop_filter("a file has a path", |(_, _, p, _)| !p.is_empty())
.prop_map(|(repo, rev, path, lines)| Route::Blob {
repo,
rev,
path,
lines: lines.map(|(a, b)| (a.min(b), a.max(b))),
}),
(repo(), query()).prop_map(|(repo, query)| Route::Issues { repo, query }),
(repo(), query()).prop_map(|(repo, query)| Route::Pulls { repo, query }),
(repo(), 1..u64::MAX).prop_map(|(repo, number)| Route::Issue { repo, number }),
(repo(), "[0-9a-f]{7,40}").prop_map(|(repo, oid)| Route::Commit { repo, oid }),
(repo(), rev, path()).prop_map(|(repo, rev, path)| Route::Commits {
repo,
rev,
path
}),
(repo(), 1..u64::MAX).prop_map(|(repo, number)| Route::Pr {
pr: PrRef { repo, number },
tab: PrTab::Commits,
}),
login()
.prop_filter("not a reserved path", |l| !RESERVED.contains(&l.as_str()))
.prop_flat_map(|login| {
let tabs = prop::sample::select(vec![
ProfileTab::Overview,
ProfileTab::Repositories(RepoSort::Updated),
ProfileTab::Repositories(RepoSort::Name),
ProfileTab::Repositories(RepoSort::Stars),
ProfileTab::Stars,
ProfileTab::Followers,
ProfileTab::Following,
ProfileTab::People,
]);
tabs.prop_map(move |tab| Route::User {
login: login.clone(),
tab,
})
}),
(any::<String>(), 0..7u8).prop_map(|(query, k)| Route::Search {
kind: [
SearchKind::Repos,
SearchKind::Issues,
SearchKind::Pulls,
SearchKind::Users,
SearchKind::Discussions,
SearchKind::Commits,
SearchKind::Code,
][usize::from(k)],
query,
}),
]
}
fn vary(url: &str, owner: &str, name: &str, number: u64, sha: &str) -> String {
let Ok(mut parsed) = url::Url::parse(url) else {
return url.to_owned();
};
let segments: Vec<String> = parsed
.path_segments()
.map(|s| s.map(str::to_owned).collect())
.unwrap_or_default();
let repo_page = parsed.host_str() == Some("github.com")
&& segments.len() >= 2
&& segments
.first()
.is_some_and(|s| !RESERVED.contains(&s.as_str()) && s != "orgs");
let varied: Vec<String> = segments
.iter()
.enumerate()
.map(|(i, s)| match (i, repo_page) {
(0, true) => owner.to_owned(),
(1, true) => name.to_owned(),
_ if s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit()) => {
sha.to_owned()
}
_ if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) => {
let digits = u32::try_from(s.len()).unwrap_or(u32::MAX);
let low = 10u64.checked_pow(digits - 1).unwrap_or(1);
let high = 10u64.checked_pow(digits).unwrap_or(u64::MAX);
(low + number % (high - low)).to_string()
}
_ => s.clone(),
})
.collect();
parsed.set_path(&varied.join("/"));
parsed.to_string()
}
proptest! {
#[test]
fn corpus_shapes_hold_for_any_names(
owner in segment(),
name in segment(),
number in 1..u64::MAX,
sha in "[0-9a-f]{40}",
) {
for (url, expect, reason) in corpus() {
let varied = vary(url, &owner, &name, number, &sha);
let target = Dest::from_url(&varied).target;
prop_assert!(as_expected(&target, expect, reason), "{varied}: {target:?}, not {expect}");
if let Target::Page(route) = &target {
let route = route.split(None).unwrap_or_else(|| route.clone());
prop_assert_eq!(reopened(&route), Target::Page(route.clone()), "{}", varied);
}
}
}
#[test]
fn urls_round_trip(route in route()) {
prop_assert_eq!(reopened(&route), Target::Page(route));
}
#[test]
fn any_input_parses_or_not(input in any::<String>()) {
let repo = RepoId::new("o", "r");
let _ = parse_input(&input, Some(&repo));
let _ = Dest::from_url(&input).target;
let _ = decode(&input);
}
}
}
}