use std::path::Path;
use std::process::Output;
use serde_json::Value;
use crate::detect::{Detection, Forge};
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
pub const GITLAB_DEFAULT_TEMPLATE: &str = "%{id}-%{title}";
const GITLAB_NAME_CAP: usize = 100;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reference {
pub number: u64,
pub repo: Option<String>,
pub host: Option<String>,
}
const ACCEPTED_FORMS: &str = "a number, #<number>, or the forge's issue URL (…/issues/<number> on GitHub, …/-/issues/<number> on GitLab)";
pub fn parse_reference(text: &str) -> Result<Reference, String> {
let text = text.trim();
let bare = text.strip_prefix('#').unwrap_or(text);
if !bare.is_empty() && bare.chars().all(|c| c.is_ascii_digit()) {
let number = bare
.parse()
.map_err(|_| format!("'{text}' is not an issue number this forge can carry"))?;
return numbered(number, None, None, text);
}
let Some((host, path)) = crate::detect::split_remote(text) else {
return Err(format!(
"'{text}' is not an issue reference; pass {ACCEPTED_FORMS}"
));
};
let path = path
.split(['?', '#'])
.next()
.unwrap_or_default()
.trim_end_matches('/');
let split = path
.rsplit_once("/-/issues/")
.or_else(|| path.rsplit_once("/issues/"));
let Some((repo, tail)) = split else {
return Err(format!("'{text}' names no issue; pass {ACCEPTED_FORMS}"));
};
let number = tail
.split('/')
.next()
.unwrap_or_default()
.parse()
.map_err(|_| format!("'{text}' names no issue number; pass {ACCEPTED_FORMS}"))?;
numbered(number, Some(repo.to_owned()), Some(host), text)
}
fn numbered(
number: u64,
repo: Option<String>,
host: Option<String>,
text: &str,
) -> Result<Reference, String> {
if number == 0 {
return Err(format!("'{text}' names issue 0, which no forge carries"));
}
Ok(Reference { number, repo, host })
}
pub fn agrees(reference: &Reference, detected: &Detection) -> Result<(), String> {
if let (Some(named), Some(found)) = (reference.host.as_deref(), detected.host.as_deref()) {
if !named.eq_ignore_ascii_case(found) {
return Err(format!(
"the reference names {named} and this clone's origin is {found}; pass the issue number instead where one instance serves both names"
));
}
}
if let (Some(named), Some(found)) = (reference.repo.as_deref(), detected.repo.as_deref()) {
if named != found {
return Err(format!(
"the reference names {named} and this clone's origin is {found}"
));
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rendered {
pub name: String,
pub approximated: bool,
}
#[must_use]
pub fn gitlab_branch_name(
iid: u64,
title: &str,
confidential: bool,
template: Option<&str>,
branch_creator: Option<&str>,
) -> Rendered {
if confidential {
return cap(format!("{iid}-confidential-issue"), false);
}
let id = parameterize_reporting(&iid.to_string(), true);
let title = parameterize_reporting(title, false);
let creator = branch_creator.map(|name| parameterize_reporting(name, true));
let approximated =
id.approximated || title.approximated || creator.as_ref().is_some_and(|c| c.approximated);
let name = match template.filter(|text| !text.trim().is_empty()) {
None => [id.name, title.name]
.into_iter()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-"),
Some(template) => substitute(
template,
&id.name,
&title.name,
creator.as_ref().map_or("", |c| c.name.as_str()),
),
};
cap(name, approximated)
}
fn substitute(template: &str, id: &str, title: &str, creator: &str) -> String {
let mut name = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find("%{") {
name.push_str(&rest[..open]);
let after = &rest[open + 2..];
let Some(close) = after.find('}') else {
rest = &rest[open..];
break;
};
let key = &after[..close];
let value = match key {
"id" => id,
"title" => title,
"branch_creator" => creator,
_ => "",
};
if value.is_empty() {
name.push_str(&rest[open..=(open + 2 + close)]);
} else {
name.push_str(value);
}
rest = &after[close + 1..];
}
name.push_str(rest);
name
}
fn cap(name: String, approximated: bool) -> Rendered {
if name.chars().count() <= GITLAB_NAME_CAP {
return Rendered { name, approximated };
}
let cut: String = name.chars().take(GITLAB_NAME_CAP).collect();
let name = cut
.rfind('-')
.map_or_else(|| cut.clone(), |at| cut[..at].to_owned());
Rendered { name, approximated }
}
#[must_use]
pub fn parameterize(text: &str, preserve_case: bool) -> String {
parameterize_reporting(text, preserve_case).name
}
#[must_use]
pub fn parameterize_reporting(text: &str, preserve_case: bool) -> Rendered {
let mut approximated = false;
let mut transliterated = String::with_capacity(text.len());
for source in text.chars() {
if source.is_ascii() {
transliterated.push(source);
} else if let Some(ascii) = transliterate(source) {
transliterated.push_str(ascii);
} else {
approximated = true;
transliterated.push('?');
}
}
let mut replaced = String::with_capacity(transliterated.len());
let mut in_run = false;
for held in transliterated.chars() {
if held.is_ascii_alphanumeric() || matches!(held, '_' | '-') {
replaced.push(held);
in_run = false;
} else if !in_run {
replaced.push('-');
in_run = true;
}
}
let mut squeezed = String::with_capacity(replaced.len());
let mut last_was_separator = false;
for held in replaced.chars() {
if held == '-' {
if last_was_separator {
continue;
}
last_was_separator = true;
} else {
last_was_separator = false;
}
squeezed.push(held);
}
let trimmed = squeezed.trim_matches('-');
let name = if preserve_case {
trimmed.to_owned()
} else {
trimmed.to_lowercase()
};
Rendered { name, approximated }
}
fn transliterate(source: char) -> Option<&'static str> {
let index = (source as u32).checked_sub(0x00C0)? as usize;
TRANSLITERATIONS
.get(index)
.copied()
.filter(|s| !s.is_empty())
}
const TRANSLITERATIONS: [&str; 192] = [
"A", "A", "A", "A", "A", "A", "AE", "C", "E", "E", "E", "E", "I", "I", "I", "I", "D", "N", "O",
"O", "O", "O", "O", "x", "O", "U", "U", "U", "U", "Y", "Th", "ss", "a", "a", "a", "a", "a",
"a", "ae", "c", "e", "e", "e", "e", "i", "i", "i", "i", "d", "n", "o", "o", "o", "o", "o", "",
"o", "u", "u", "u", "u", "y", "th", "y", "A", "a", "A", "a", "A", "a", "C", "c", "C", "c", "C", "c", "C", "c", "D", "d", "D", "d", "E",
"e", "E", "e", "E", "e", "E", "e", "E", "e", "G", "g", "G", "g", "G", "g", "G", "g", "H", "h",
"H", "h", "I", "i", "I", "i", "I", "i", "I", "i", "I", "i", "IJ", "ij", "J", "j", "K", "k",
"k", "L", "l", "L", "l", "L", "l", "L", "l", "L", "l", "N", "n", "N", "n", "N", "n", "n", "NG",
"ng", "O", "o", "O", "o", "O", "o", "OE", "oe", "R", "r", "R", "r", "R", "r", "S", "s", "S",
"s", "S", "s", "S", "s", "T", "t", "T", "t", "T", "t", "U", "u", "U", "u", "U", "u", "U", "u",
"U", "u", "U", "u", "W", "w", "Y", "y", "Y", "Z", "z", "Z", "z", "Z", "z", "s",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Minted {
Already {
branch: String,
others: Vec<String>,
},
Absent,
Unknown {
detail: String,
},
}
#[must_use]
pub fn linked_branch(body: &Value) -> Minted {
let nodes = body
.pointer("/data/repository/issue/linkedBranches/nodes")
.and_then(Value::as_array);
let Some(nodes) = nodes else {
return Minted::Unknown {
detail: "the answer carries no linkedBranches list".to_owned(),
};
};
match body
.pointer("/data/repository/issue/linkedBranches/pageInfo/hasNextPage")
.and_then(Value::as_bool)
{
Some(false) => {}
Some(true) => {
return Minted::Unknown {
detail: format!(
"the issue links more than the {LINKED_BRANCH_PAGE} branches one read carries"
),
};
}
None => {
return Minted::Unknown {
detail: "the answer does not say whether it carries every linked branch".to_owned(),
};
}
}
let mut names = Vec::with_capacity(nodes.len());
for node in nodes {
let Some(name) = node.pointer("/ref/name").and_then(Value::as_str) else {
return Minted::Unknown {
detail: "a linked branch carries no ref name".to_owned(),
};
};
names.push(name.to_owned());
}
names.sort_unstable();
if names.is_empty() {
return Minted::Absent;
}
let branch = names.remove(0);
Minted::Already {
branch,
others: names,
}
}
const LINKED_BRANCH_PAGE: u32 = 100;
#[must_use]
pub fn admissible(branch: &str) -> bool {
crate::worktree::matches_grammar(branch)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
pub number: u64,
pub title: String,
pub branch: Option<String>,
pub origin: &'static str,
pub others: Vec<String>,
pub detail: Option<String>,
}
pub fn resolve(cli: &Path, target: &Path, ask: &Ask<'_>) -> Result<Resolved, RkError> {
match ask.forge {
Forge::Github => resolve_github(cli, target, ask),
Forge::Gitlab => resolve_gitlab(cli, target, ask),
}
}
pub struct Ask<'a> {
pub forge: Forge,
pub repo: &'a str,
pub reference: &'a Reference,
pub base: Option<&'a str>,
pub host: Option<&'a str>,
pub apply: bool,
pub seatable: &'a dyn Fn(&str) -> Result<(), RkError>,
}
fn resolve_github(cli: &Path, target: &Path, ask: &Ask<'_>) -> Result<Resolved, RkError> {
let (repo, reference, base, apply) = (ask.repo, ask.reference, ask.base, ask.apply);
let Some((owner, name)) = repo.split_once('/') else {
return Err(RkError::Usage(format!(
"'{repo}' is not a GitHub project path; pass --repo <owner/name>"
)));
};
let number = reference.number.to_string();
let read = || -> Result<Value, RkError> {
let out = forge_call(
cli,
target,
&[
"api",
"graphql",
"-f",
&format!("query={LINKED_BRANCHES_QUERY}"),
"-F",
&format!("owner={owner}"),
"-F",
&format!("name={name}"),
"-F",
&format!("number={number}"),
],
)?;
answered(&out, "the issue read")
};
let body = read()?;
let title = body
.pointer("/data/repository/issue/title")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
match linked_branch(&body) {
Minted::Already { branch, others } => Ok(Resolved {
number: reference.number,
title,
branch: Some(branch),
origin: "already",
others,
detail: None,
}),
Minted::Unknown { detail } => Err(forge_failure(format!(
"the issue read did not answer with linked branches: {detail}"
))),
Minted::Absent if !apply => Ok(Resolved {
number: reference.number,
title,
branch: None,
origin: "pending",
others: Vec::new(),
detail: Some(
"GitHub names the branch when it mints it, so the exact name appears on the apply"
.to_owned(),
),
}),
Minted::Absent => {
let mut args = vec!["issue", "develop", number.as_str(), "--repo", repo];
if let Some(base) = base {
args.push("--base");
args.push(base);
}
succeeded(&forge_call(cli, target, &args)?, "the mint")?;
let after = read()?;
match linked_branch(&after) {
Minted::Already { branch, others } => Ok(Resolved {
number: reference.number,
title,
branch: Some(branch),
origin: "forge",
others,
detail: None,
}),
_ => Err(forge_failure(
"the mint reported success and the issue still carries no linked branch"
.to_owned(),
)),
}
}
}
}
const LINKED_BRANCHES_QUERY: &str = "query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { issue(number: $number) { title linkedBranches(first: 100) { pageInfo { hasNextPage } nodes { ref { name } } } } } }";
struct Planned {
iid: u64,
title: String,
name: String,
default_branch: String,
detail: Option<String>,
}
fn plan_gitlab(
cli: &Path,
target: &Path,
encoded: &str,
reference: &Reference,
host: &[&str],
) -> Result<Planned, RkError> {
let project = answered(
&forge_call(
cli,
target,
&borrowed(&api(host, &format!("projects/{encoded}"))),
)?,
"the project read",
)?;
let template = match project.get("issue_branch_template") {
Some(Value::Null) => None,
Some(Value::String(text)) if text.trim().is_empty() => None,
Some(Value::String(text)) => Some(text.clone()),
_ => {
return Err(forge_failure(
"the project read answered without a usable 'issue_branch_template'".to_owned(),
));
}
};
let default_branch = required(&project, "default_branch", |held| {
held.as_str()
.filter(|name| !name.trim().is_empty())
.map(ToOwned::to_owned)
})
.map_err(|_| {
forge_failure("the project read answered without a usable 'default_branch'".to_owned())
})?;
let issue = answered(
&forge_call(
cli,
target,
&borrowed(&api(
host,
&format!("projects/{encoded}/issues/{}", reference.number),
)),
)?,
"the issue read",
)?;
let iid = required(&issue, "iid", Value::as_u64)?;
let title = required(&issue, "title", |held| held.as_str().map(ToOwned::to_owned))?;
let confidential = required(&issue, "confidential", Value::as_bool)?;
let creator = match template.as_deref() {
Some(text) if text.contains("%{branch_creator}") => {
let user = answered(
&forge_call(cli, target, &borrowed(&api(host, "user")))?,
"the user read",
)?;
user["username"].as_str().map(ToOwned::to_owned)
}
_ => None,
};
let rendered = gitlab_branch_name(
iid,
&title,
confidential,
template.as_deref(),
creator.as_deref(),
);
if !admissible(&rendered.name) {
return Err(refuse_template(&rendered.name, GRAMMAR_REFUSED));
}
if !links_to(&rendered.name, iid) {
return Err(refuse_template(&rendered.name, &link_refused(iid)));
}
Ok(Planned {
iid,
title,
detail: gitlab_detail(confidential, template.as_deref(), rendered.approximated),
name: rendered.name,
default_branch,
})
}
const GRAMMAR_REFUSED: &str = "a template whose names match <type>/<slug> or <issue-id>-<slug>";
#[must_use]
pub fn links_to(branch: &str, iid: u64) -> bool {
branch
.strip_prefix(&iid.to_string())
.and_then(|rest| rest.strip_prefix('-'))
.is_some_and(|slug| !slug.is_empty())
}
fn link_refused(iid: u64) -> String {
format!(
"a template whose names start with {iid}-, which is how GitLab links a branch to its issue"
)
}
fn refuse_template(name: &str, expected: &str) -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!("the project's issue_branch_template renders '{name}', which this verb cannot use"),
)
.expected(expected)
.action(
"change Settings > Repository > Branch defaults > Branch name template, or pass a branch to rk worktree add instead",
)
.target_state("unchanged"),
)
}
fn gitlab_detail(confidential: bool, template: Option<&str>, approximated: bool) -> Option<String> {
let mut notes = Vec::new();
if confidential {
notes.push(
"the issue is confidential, so GitLab keeps its title out of the branch and applies no template"
.to_owned(),
);
}
if let Some(text) = template {
notes.push(format!("the project's branch name template is '{text}'"));
}
if approximated {
notes.push(
"the title carries characters outside the transliteration table, so this name can differ from the one GitLab's own button produces"
.to_owned(),
);
}
(!notes.is_empty()).then(|| notes.join("; "))
}
fn resolve_gitlab(cli: &Path, target: &Path, ask: &Ask<'_>) -> Result<Resolved, RkError> {
let (reference, base, apply) = (ask.reference, ask.base, ask.apply);
let encoded = ask.repo.replace('/', "%2F");
let host = host_args(ask.host);
let planned = plan_gitlab(cli, target, &encoded, reference, &host)?;
let linked = linked_branches(cli, target, &encoded, planned.iid, &host)?;
if let Some((primary, others)) = pick(linked, &planned.name) {
let detail = if primary == planned.name {
planned.detail
} else {
let took =
format!("the forge already links '{primary}' to this issue, so it was taken");
Some(
planned
.detail
.map_or_else(|| took.clone(), |had| format!("{had}; {took}")),
)
};
return Ok(Resolved {
number: planned.iid,
title: planned.title,
branch: Some(primary),
origin: "already",
others,
detail,
});
}
let origin = if apply {
(ask.seatable)(&planned.name)?;
let start = base.unwrap_or(&planned.default_branch);
let mut args = api(&host, "--method");
args.push("POST".to_owned());
args.push(format!(
"projects/{encoded}/repository/branches?branch={}&ref={}",
encode(&planned.name),
encode(start)
));
forge_call(cli, target, &borrowed(&args))
.and_then(|out| succeeded(&out, "the branch creation"))?;
"forge"
} else {
"pending"
};
Ok(Resolved {
number: planned.iid,
title: planned.title,
branch: Some(planned.name),
origin,
others: Vec::new(),
detail: planned.detail,
})
}
fn pick(mut linked: Vec<String>, rendered: &str) -> Option<(String, Vec<String>)> {
if linked.is_empty() {
return None;
}
linked.sort_unstable();
let at = linked.iter().position(|name| name == rendered).unwrap_or(0);
let primary = linked.remove(at);
Some((primary, linked))
}
fn required<T>(
body: &Value,
field: &str,
read: impl Fn(&Value) -> Option<T>,
) -> Result<T, RkError> {
read(&body[field]).ok_or_else(|| {
forge_failure(format!(
"the issue read answered without a usable '{field}'"
))
})
}
fn linked_branches(
cli: &Path,
target: &Path,
encoded: &str,
iid: u64,
host: &[&str],
) -> Result<Vec<String>, RkError> {
let mut args = api(host, "--paginate");
args.push(format!(
"projects/{encoded}/repository/branches?search={}",
encode(&format!("^{iid}-"))
));
let found = forge_call(cli, target, &borrowed(&args))?;
let body = answered(&found, "the linked branch read")?;
let Some(held) = body.as_array() else {
return Err(forge_failure(
"the linked branch read did not answer with a branch list".to_owned(),
));
};
let mut names = Vec::with_capacity(held.len());
for branch in held {
let Some(name) = branch["name"].as_str() else {
return Err(forge_failure(
"a branch in the linked branch read carries no name".to_owned(),
));
};
if links_to(name, iid) {
names.push(name.to_owned());
}
}
Ok(names)
}
fn host_args(host: Option<&str>) -> Vec<&str> {
host.map_or_else(Vec::new, |host| vec!["--hostname", host])
}
fn api(host: &[&str], rest: &str) -> Vec<String> {
let mut args = vec!["api".to_owned()];
args.extend(host.iter().map(|held| (*held).to_owned()));
args.push(rest.to_owned());
args
}
fn borrowed(args: &[String]) -> Vec<&str> {
args.iter().map(String::as_str).collect()
}
fn forge_call(cli: &Path, target: &Path, args: &[&str]) -> Result<Output, RkError> {
std::process::Command::new(cli)
.args(args)
.current_dir(target)
.env("GH_PAGER", "")
.env("GLAB_PAGER", "")
.output()
.map_err(|source| {
RkError::subprocess(
Diagnostic::new(
Reason::SubprocessSpawn,
format!("the forge CLI did not run: {source}"),
)
.target_state("unchanged"),
)
})
}
fn answered(out: &Output, what: &str) -> Result<Value, RkError> {
succeeded(out, what)?;
serde_json::from_slice(&out.stdout)
.map_err(|_| forge_failure(format!("{what} did not answer with JSON")))
}
fn succeeded(out: &Output, what: &str) -> Result<(), RkError> {
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
Err(forge_failure_from(
format!("{what} failed: {}", last_line(&out.stderr)),
&stderr,
))
}
fn forge_failure(message: String) -> RkError {
forge_failure_from(message, "")
}
fn forge_failure_from(message: String, stderr: &str) -> RkError {
let (reason, action) = classify_forge_answer(stderr);
let diagnostic = Diagnostic::new(reason, message)
.action(action)
.target_state("unchanged");
match reason {
Reason::ForgeAuthentication
| Reason::ForgePermission
| Reason::ForgeRateLimit
| Reason::RemoteConflict => RkError::refusal(diagnostic),
Reason::TargetNotFound => RkError::missing(diagnostic),
_ => RkError::subprocess(diagnostic),
}
}
fn classify_forge_answer(stderr: &str) -> (Reason, &'static str) {
let text = stderr.to_ascii_lowercase();
let status =
|code: &str| text.contains(&format!("http {code}")) || text.contains(&format!("{code} "));
if text.contains("rate limit") || status("429") {
return (
Reason::ForgeRateLimit,
"wait for the forge's limit to reset, then rerun",
);
}
if status("401") || text.contains("not logged in") || text.contains("authentication") {
return (
Reason::ForgeAuthentication,
"authenticate the forge CLI, then rerun",
);
}
if status("403") {
return (
Reason::ForgePermission,
"grant this account access to the project, then rerun",
);
}
if status("404") {
return (
Reason::TargetNotFound,
"check the issue number and the project, then rerun",
);
}
if status("409") {
return (
Reason::RemoteConflict,
"read what the forge already carries, then rerun",
);
}
if status("500") || status("502") || status("503") || status("504") {
return (
Reason::ForgeTemporary,
"rerun; the forge failed transiently",
);
}
(
Reason::SubprocessFailed,
"read the forge's own answer, then decide",
)
}
fn last_line(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes)
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.unwrap_or("no output")
.to_owned()
}
fn encode(text: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(text.len());
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
out.push(byte as char);
} else {
let _ = write!(out, "%{byte:02X}");
}
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{
Minted, Reference, admissible, agrees, gitlab_branch_name, linked_branch, parameterize,
parse_reference,
};
use crate::detect::Detection;
fn clone_of(host: &str, repo: &str) -> Detection {
Detection {
host: Some(host.to_owned()),
repo: Some(repo.to_owned()),
forge: None,
}
}
#[test]
fn a_reference_parses_from_every_accepted_form() {
assert_eq!(
parse_reference("57").expect("a number parses"),
Reference {
number: 57,
repo: None,
host: None
}
);
assert_eq!(
parse_reference("#57").expect("a hashed number parses"),
Reference {
number: 57,
repo: None,
host: None
}
);
assert_eq!(
parse_reference("https://github.com/acme/widget/issues/57").expect("a URL parses"),
Reference {
number: 57,
repo: Some("acme/widget".into()),
host: Some("github.com".into())
}
);
assert_eq!(
parse_reference("https://gitlab.example.com/acme/widget/-/issues/57")
.expect("a self-hosted URL parses"),
Reference {
number: 57,
repo: Some("acme/widget".into()),
host: Some("gitlab.example.com".into())
}
);
assert!(parse_reference("nonsense").is_err());
assert!(parse_reference("0").is_err(), "no forge carries issue 0");
}
#[test]
fn a_nested_gitlab_group_keeps_every_segment() {
let parsed = parse_reference("https://gitlab.com/acme/team/widget/-/issues/57#note_9")
.expect("a nested URL parses");
assert_eq!(parsed.repo.as_deref(), Some("acme/team/widget"));
assert_eq!(parsed.number, 57);
}
#[test]
fn a_reference_that_names_another_project_disagrees() {
let parsed =
parse_reference("https://github.com/other/thing/issues/1").expect("a URL parses");
assert!(agrees(&parsed, &clone_of("github.com", "acme/widget")).is_err());
}
#[test]
fn a_bare_number_agrees_with_any_clone() {
let parsed = parse_reference("57").expect("a number parses");
assert!(agrees(&parsed, &clone_of("github.com", "acme/widget")).is_ok());
assert!(agrees(&parsed, &clone_of("gitlab.com", "other/thing")).is_ok());
}
#[test]
fn parameterize_matches_the_documented_example() {
assert_eq!(parameterize("^très|Jolie-- ", false), "tres-jolie");
}
#[test]
fn parameterize_preserves_case_when_asked() {
assert_eq!(parameterize("Donald E. Knuth", true), "Donald-E-Knuth");
assert_eq!(parameterize("Donald E. Knuth", false), "donald-e-knuth");
}
#[test]
fn a_name_renders_from_id_and_title_without_a_template() {
let rendered = gitlab_branch_name(57, "Fix the CSV upload!", false, None, None);
assert_eq!(rendered.name, "57-fix-the-csv-upload");
assert!(!rendered.approximated);
assert!(admissible(&rendered.name));
}
#[test]
fn an_empty_title_yields_the_bare_number() {
assert_eq!(gitlab_branch_name(57, "", false, None, None).name, "57");
}
#[test]
fn a_template_substitutes_every_supported_variable() {
let rendered = gitlab_branch_name(
57,
"Fix the CSV upload",
false,
Some("%{branch_creator}-%{id}-%{title}"),
Some("Ada Lovelace"),
);
assert_eq!(rendered.name, "Ada-Lovelace-57-fix-the-csv-upload");
}
#[test]
fn an_unknown_placeholder_survives_into_the_name() {
let rendered = gitlab_branch_name(57, "Upload", false, Some("%{author}-%{id}"), None);
assert_eq!(rendered.name, "%{author}-57");
assert!(!admissible(&rendered.name));
}
#[test]
fn a_confidential_issue_ignores_the_template() {
let rendered = gitlab_branch_name(
57,
"The secret title",
true,
Some("%{id}-%{title}"),
Some("ada"),
);
assert_eq!(rendered.name, "57-confidential-issue");
assert!(admissible(&rendered.name));
}
#[test]
fn a_long_name_truncates_at_100_and_drops_the_partial_segment() {
let title = "alpha bravo charlie delta echo foxtrot golf hotel india juliett kilo lima mike november oscar papa";
let rendered = gitlab_branch_name(57, title, false, None, None);
assert!(rendered.name.len() <= 100, "{}", rendered.name);
assert!(
rendered.name.ends_with("-oscar"),
"the partial trailing segment is dropped: {}",
rendered.name
);
assert!(
!rendered.name.contains("papa"),
"the cut segment does not survive: {}",
rendered.name
);
}
#[test]
fn a_title_outside_the_table_reports_approximated() {
let rendered = gitlab_branch_name(57, "Исправить загрузку", false, None, None);
assert!(rendered.approximated);
assert_eq!(rendered.name, "57");
}
#[test]
fn a_linked_branch_answer_judges_absent_one_and_many() {
let answer = |names: &[&str]| {
let nodes: Vec<_> = names
.iter()
.map(|name| serde_json::json!({ "ref": { "name": name } }))
.collect();
serde_json::json!({
"data": { "repository": { "issue": { "linkedBranches": {
"pageInfo": { "hasNextPage": false },
"nodes": nodes
} } } }
})
};
assert_eq!(linked_branch(&answer(&[])), Minted::Absent);
assert_eq!(
linked_branch(&answer(&["57-fix"])),
Minted::Already {
branch: "57-fix".into(),
others: vec![]
}
);
assert_eq!(
linked_branch(&answer(&["57-fix-again", "57-fix"])),
Minted::Already {
branch: "57-fix".into(),
others: vec!["57-fix-again".into()]
}
);
}
#[test]
fn a_malformed_linked_branch_answer_is_unknown() {
let body = serde_json::json!({ "errors": [{ "message": "Could not resolve" }] });
assert!(matches!(linked_branch(&body), Minted::Unknown { .. }));
}
#[test]
fn a_customized_template_can_render_a_name_the_grammar_refuses() {
let rendered = gitlab_branch_name(
57,
"Fix the upload",
false,
Some("feature/%{id}-%{title}"),
None,
);
assert_eq!(rendered.name, "feature/57-fix-the-upload");
assert!(!admissible(&rendered.name));
}
}