hyperhive/hive-forge/src/verbs/mod.rs
atlas 55f01942a2 docs, prompts, hive-forge: stop handing readers the renamed verbs
docs/tools/forge.md already listed the nine renamed verbs as removed, then
used them ~30 more times in pasteable blocks. Sweeps every occurrence a
reader would type, including three runtime messages that told the user to
run a verb the same binary rejects.

The renamed-verb list itself keeps the old names; it is what documents them.

Refs #4155
2026-09-10 17:22:57 +02:00

689 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Per-verb subcommand modules. Each module exposes a `Args` struct
//! (clap-derived) and a `run` fn taking `(&Client, Args) -> Result<()>`.
//! Splitting one verb per module keeps each handler small and avoids
//! the bash script's monolithic `case` statement.
pub mod artifact_get;
pub mod assign;
pub mod attach;
pub mod attachment_get;
pub mod branches;
pub mod ci_common;
pub mod ci_log;
pub mod ci_rerun;
pub mod ci_runs;
pub mod clone;
pub mod close;
pub mod comment;
pub mod comment_edit;
pub mod comment_show;
pub mod comments;
pub mod credential_helper;
pub mod dependency;
pub mod diff;
pub mod issue;
pub mod issue_cmd;
pub mod issue_create;
pub mod issue_edit;
pub mod labels;
pub mod lint;
pub mod list;
pub mod milestone;
pub mod pr;
pub mod pr_assign_reviewer;
pub mod pr_cmd;
pub mod pr_commits;
pub mod pr_create;
pub mod pr_merge;
pub mod pr_reviews;
pub mod pr_status;
pub mod reaction;
pub mod reopen;
pub mod repo_add_collaborator;
pub mod repo_create;
pub mod repo_labels;
pub mod repo_search;
pub mod subscription;
pub mod timeline;
pub mod tree_sha;
pub mod view;
use std::fmt::Write as _;
use anyhow::Result;
use forgejo_api::structs::{Attachment, Reaction};
use serde::Deserialize;
use serde_json::{Value, json};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::client::{Client, index};
/// Pretty-print a `serde_json` value to stdout with a trailing newline,
/// matching the bash script's `| jq` output shape.
pub(crate) fn print_json(v: &Value) -> Result<()> {
let s = serde_json::to_string_pretty(v)?;
println!("{s}");
Ok(())
}
/// Format an optional timestamp as its RFC 3339 string — the shape the
/// raw API emitted, so output stays stable across the typed-client
/// port. `None` (and the never-in-practice unformattable timestamp)
/// map to `None` so callers keep their existing null/placeholder
/// handling.
pub(crate) fn rfc3339(ts: Option<OffsetDateTime>) -> Option<String> {
ts.and_then(|t| t.format(&Rfc3339).ok())
}
/// Parse a `--since`/`--before` CLI argument as RFC 3339 — the inverse of
/// [`rfc3339`], so a value copied straight from this tool's own output
/// (every row prints its `created_at` in this exact shape) round-trips
/// without reformatting. A bad value gets a message naming what was
/// typed, not a bare parser error.
pub(crate) fn parse_rfc3339(s: &str) -> Result<OffsetDateTime> {
OffsetDateTime::parse(s, &Rfc3339)
.map_err(|e| anyhow::anyhow!("`{s}` isn't a valid RFC 3339 timestamp: {e}"))
}
/// Forgejo's per-page cap, shared by every listing verb that over-fetches
/// by one to detect truncation without an exact total (`timeline`'s
/// `--limit`, `comments`' `--since`). The API silently clamps a requested
/// page size to this value, so it's pinned explicitly rather than left as
/// a hidden default downstream math could drift out of sync with.
pub(crate) const PAGE_SIZE: u64 = 50;
/// The highest `--limit` an over-fetch-by-one truncation check
/// (`fetch_limit = limit + 1`) can still detect: `PAGE_SIZE - 1`. At
/// `limit == PAGE_SIZE` the `+1` request silently clamps to `PAGE_SIZE`
/// server-side and the truncation check goes blind exactly when there's
/// the most data to miss.
pub(crate) const MAX_LIMIT: u64 = PAGE_SIZE - 1;
/// Cap `requested` at [`MAX_LIMIT`], reporting whether it had to. Pure so
/// the boundary math is unit-testable without a network call.
pub(crate) fn clamp_limit(requested: u64) -> (u64, bool) {
let limit = requested.min(MAX_LIMIT);
(limit, limit < requested)
}
#[cfg(test)]
mod page_limit_tests {
use super::{MAX_LIMIT, PAGE_SIZE, clamp_limit};
#[test]
fn clamp_limit_passes_small_requests_through() {
assert_eq!(clamp_limit(10), (10, false));
assert_eq!(clamp_limit(MAX_LIMIT), (MAX_LIMIT, false));
}
#[test]
fn clamp_limit_caps_requests_above_the_boundary() {
// Regression: `limit + 1` must never exceed Forgejo's PAGE_SIZE,
// or the over-fetch-by-one truncation check goes silently blind.
assert_eq!(clamp_limit(PAGE_SIZE), (MAX_LIMIT, true));
assert_eq!(clamp_limit(1000), (MAX_LIMIT, true));
}
}
/// Issue-vs-PR kind, for the `pr <verb>` / `issue <verb>` sub-command
/// validation.
#[derive(Clone, Copy)]
pub(crate) enum Kind {
Pr,
Issue,
}
/// Whether `number` is a PR rather than a plain issue. Forgejo's
/// `/issues/{n}` endpoint serves both and marks PRs with a non-null
/// `pull_request` field, so one GET classifies it — shared by
/// [`assert_kind`] and by `dependency`'s failure-diagnosis path, which
/// needs the same classification without wanting an error on mismatch.
pub(crate) fn is_pr(client: &Client, owner: &str, name: &str, number: u64) -> Result<bool> {
let issue = client
.api()
.issue_get_issue(owner, name, index(number)?)
.send()?;
Ok(issue.pull_request.is_some())
}
/// Verify `number` is the expected kind before a kind-namespaced verb (one
/// of the generics that work on both — close/comment/labels/…) acts on it —
/// the validation win the `pr <verb>` / `issue <verb>` split buys over the
/// old generic verbs. Errors with a "use the other command" message on
/// mismatch.
pub(crate) fn assert_kind(client: &Client, number: u64, expected: Kind) -> Result<()> {
let (owner, name) = client.owner_repo()?;
let is_pr_result = is_pr(client, owner, name, number)?;
match (expected, is_pr_result) {
(Kind::Pr, false) => {
anyhow::bail!(
"#{number} is an issue, not a PR — use `hive-forge issue <verb> {number}`"
)
}
(Kind::Issue, true) => {
anyhow::bail!("#{number} is a PR, not an issue — use `hive-forge pr <verb> {number}`")
}
_ => Ok(()),
}
}
/// Minimal RFC 3986 unreserved-set percent encoder. Covers the subset of
/// characters that show up in the values we splice into *web-route* paths
/// (the typed client encodes its own path segments) — artifact names —
/// without pulling in a fresh workspace dep. Unreserved bytes
/// (`[A-Za-z0-9-._~]`) pass through, so the common identifier case is a
/// no-op; everything else is `%XX`-escaped. Used by `artifact-get` (the
/// artifact-name path segment on the web download route).
pub(crate) fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(b as char);
} else {
write!(out, "%{b:02X}").unwrap();
}
}
out
}
/// Render an unresolved-name list with a `did you mean` where one fits.
///
/// A filter value that doesn't resolve is a **near-miss far more often
/// than an invention** (`area/opps` for `area/ops`), and an error that
/// only lists all 18 available names makes the reader do the diff by eye
/// — on the one occasion they already know they mistyped something.
pub(crate) fn with_suggestions(unresolved: &[&str], available: &[&str]) -> String {
unresolved
.iter()
.map(|u| match nearest(u, available) {
Some(s) => format!("{u} (did you mean \"{s}\"?)"),
None => (*u).to_owned(),
})
.collect::<Vec<_>>()
.join(", ")
}
/// Closest candidate to `needle`, when one is close enough to be worth
/// suggesting.
///
/// Thresholded rather than always returning the minimum: **a wrong
/// suggestion is worse than none**, because it invites a second failed
/// attempt at a name that was never there. The bound scales with the
/// needle (a third of its length, capped at 3) so a short name doesn't
/// match half the repo and a long one still tolerates a typo or two.
fn nearest<'a>(needle: &str, candidates: &[&'a str]) -> Option<&'a str> {
let limit = (needle.chars().count() / 3).clamp(1, 3);
candidates
.iter()
.map(|c| (edit_distance(needle, c), *c))
.filter(|(d, _)| *d <= limit)
// Tie-break on the shorter candidate, then alphabetically, so the
// suggestion is stable rather than dependent on the order the
// forge happened to return its labels in.
.min_by_key(|(d, c)| (*d, c.len(), *c))
.map(|(_, c)| c)
}
/// Levenshtein distance, two-row DP. Small enough not to justify a
/// workspace dependency for the one place it is used.
fn edit_distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0_usize; b.len() + 1];
for (i, ca) in a.chars().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != *cb);
cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
/// One reviewer's latest verdict on a PR, plus forgejo's `stale` /
/// `dismissed` bits.
///
/// `stale` is set by forgejo when the PR head commit changed after this
/// review was submitted (branch protection then wants a fresh review);
/// `dismissed` is set when the review was explicitly dismissed. Either way
/// the review no longer applies to the current head even though its `state`
/// string still reads `APPROVED` / `REQUEST_CHANGES` — so surfacing them
/// stops the CLI from reporting a no-longer-valid review as still-good, and
/// [`ReviewInfo::superseded`] rolls both into one "doesn't count" check.
///
/// ⚠️ **`stale` here is not forgejo's flag alone.** That flag is eventually
/// consistent: seconds after a push it still reports the pre-push answer, so
/// a verdict against the previous head reads as current in exactly the window
/// where someone runs the CLI right after pushing. [`latest_reviews`] therefore
/// ORs it with a direct comparison of the review's own `commit_id` against the
/// PR head — the flag is right *eventually*, the comparison is right
/// *immediately*, and either alone is worse than both.
pub(crate) struct ReviewInfo {
pub login: String,
pub state: String,
pub stale: bool,
pub dismissed: bool,
}
impl ReviewInfo {
/// True when the review no longer applies to the current head — stale
/// (head moved) or dismissed. Such a verdict neither blocks a merge nor
/// counts as a fresh approval.
pub(crate) fn superseded(&self) -> bool {
self.stale || self.dismissed
}
}
/// Whether this review was submitted against a commit that is no longer the
/// PR head — the race-free half of the staleness check.
///
/// Fails **closed on unknowns**, i.e. "not stale": either side missing means
/// we cannot show the head moved, and the cost of guessing wrong in that
/// direction is one redundant re-review, where the other direction would
/// silently void every verdict on the PR (blocking nothing, but reporting a
/// ready PR as unreviewed and inviting a re-request that dismisses a real
/// approval).
fn reviewed_older_head(reviewed_sha: Option<&str>, head_sha: Option<&str>) -> bool {
let (Some(head), Some(reviewed)) = (head_sha, reviewed_sha) else {
return false;
};
// Both emptiness checks matter, and for the same reason: a blank string
// is a value the forge sent, not a sha it has. Treating one as real would
// make every review compare unequal and mark the whole PR stale — the
// direction this whole function exists to avoid.
!head.is_empty() && !reviewed.is_empty() && reviewed != head
}
/// Latest non-comment review per reviewer on a PR. Reviews come
/// oldest-first, so a later verdict from the same user supersedes an
/// earlier one; `COMMENT` / `PENDING` reviews carry no verdict and are
/// skipped. Shared by `pr status` (health view + readiness verdict) and
/// `pr merge` (pre-merge changes-requested gate) so the verdict semantics
/// stay in one place.
///
/// # Errors
///
/// Propagates the forge API errors from listing the PR's reviews.
pub(crate) fn latest_reviews(client: &Client, repo: &str, pr: u64) -> Result<Vec<ReviewInfo>> {
let (owner, name) = crate::client::split_repo(repo)?;
let pr = index(pr)?;
// The head this PR currently points at, used to age out verdicts forgejo
// has not marked stale yet (see `ReviewInfo`). Best-effort: on any failure
// we fall back to forgejo's flag alone, which is today's behaviour — a
// missing head must never make every review look superseded.
let head_sha = client
.api()
.repo_get_pull_request(owner, name, pr)
.send()
.ok()
.and_then(|pull| pull.head.as_ref().and_then(|h| h.sha.clone()));
// Paginate (50/page, 10-page runaway cap — same ceiling the raw
// client used) so a heavily re-reviewed PR doesn't truncate.
let mut reviews = Vec::new();
for page in 1..=10u32 {
let (_, batch) = client
.api()
.repo_list_pull_reviews(owner, name, pr)
.page(page)
.page_size(50)
.send()?;
let short = batch.len() < 50;
reviews.extend(batch);
if short {
break;
}
}
let mut latest: Vec<ReviewInfo> = Vec::new();
for r in &reviews {
let Some(login) = r.user.as_ref().and_then(|u| u.login.as_deref()) else {
continue;
};
let st = r.state.as_deref().unwrap_or("");
if st == "COMMENT" || st == "PENDING" || st.is_empty() {
continue;
}
let stale = r.stale.unwrap_or(false)
|| reviewed_older_head(r.commit_id.as_deref(), head_sha.as_deref());
let dismissed = r.dismissed.unwrap_or(false);
if let Some(slot) = latest.iter_mut().find(|info| info.login == login) {
st.clone_into(&mut slot.state);
slot.stale = stale;
slot.dismissed = dismissed;
} else {
latest.push(ReviewInfo {
login: login.to_owned(),
state: st.to_owned(),
stale,
dismissed,
});
}
}
Ok(latest)
}
/// The current dependency list for an issue or PR — each entry names
/// another issue/PR this one is blocked on, with its `number`/`title`/
/// `state`. Forgejo's dependency endpoint works on the shared issue/PR
/// index (PRs are issues internally under the hood), so `issue show`,
/// `pr show`, and `list`'s dep-progress annotation all call this instead
/// of duplicating the fetch-and-shape step. A reviewer asked whether
/// `show`/`view` surface dependencies — they didn't (only `timeline`
/// rendered them, as history); this is the current-state complement.
/// `state` was added alongside `list`'s annotation so a caller can tell
/// open deps from closed ones without a second fetch.
///
/// # Errors
///
/// Propagates the forge API errors from listing dependencies.
pub(crate) fn dependency_summaries(
client: &Client,
owner: &str,
name: &str,
number: u64,
) -> Result<Vec<Value>> {
let deps = client
.api()
.issue_list_issue_dependencies(owner, name, index(number)?)
.send()?;
Ok(deps
.into_iter()
.map(|d| json!({ "number": d.number, "title": d.title, "state": d.state }))
.collect())
}
/// One entry in a `GET .../blocks` response — same `number`/`title`/
/// `state` shape [`dependency_summaries`] maps down to, but only those
/// fields: this struct exists to be lenient (see
/// [`Client::get_api_json`]'s doc comment), not to model the full Issue
/// response body.
#[derive(Deserialize)]
struct BlockingIssue {
#[serde(default)]
number: u64,
#[serde(default)]
title: String,
#[serde(default)]
state: String,
}
/// The issues *blocked by* `number` — the reverse of
/// [`dependency_summaries`]. Forgejo's dependency API is one-directional
/// in `forgejo-api`'s generated client (only the forward `GET
/// .../dependencies` is wrapped), but the reverse route is real — `GET
/// .../blocks`, confirmed against Forgejo/Gitea's actual API surface, just
/// not covered by the crate — so this goes through
/// [`Client::get_api_json`] instead, same escape hatch [`issue_reactions`]
/// already uses for an uncovered route.
///
/// # Errors
///
/// Propagates the forge API errors from listing blocking issues.
pub(crate) fn blocking_summaries(
client: &Client,
owner: &str,
name: &str,
number: u64,
) -> Result<Vec<Value>> {
let blocking: NullableVec<BlockingIssue> = client.get_api_json(
&format!("/repos/{owner}/{name}/issues/{number}/blocks"),
&[],
)?;
Ok(blocking
.0
.into_iter()
.map(|b| json!({ "number": b.number, "title": b.title, "state": b.state }))
.collect())
}
/// Deserialize a possibly-null JSON array as an empty `Vec`.
///
/// Several Forgejo list endpoints return an explicit `null` body instead of
/// `[]` when the collection is empty — a commit's combined-status
/// `statuses` field when no CI is configured (`pr_status`'s own use of
/// this), and issue/comment reactions when nothing has reacted yet (a real
/// incident: `issue show`/`pr show` failing on *every* item because every
/// item's reaction fetch hit this). `#[serde(default)]` alone only
/// covers a *missing* key — a *present* `null` still fails to deserialize
/// into a bare `Vec<T>`, which is why this explicit `deserialize_with` is
/// needed rather than the derive default.
pub(crate) fn null_as_empty<'de, D, T>(de: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Ok(Option::<Vec<T>>::deserialize(de)?.unwrap_or_default())
}
/// A JSON array that tolerates Forgejo's null-for-empty quirk (see
/// [`null_as_empty`]). Used to fetch reactions directly via
/// [`Client::get_api_json`] rather than through forgejo-api's generated
/// client, which deserializes straight into a bare `Vec<Reaction>` with no
/// null tolerance and fails on exactly the common case of "nothing has
/// reacted to this yet".
struct NullableVec<T>(Vec<T>);
impl<'de, T: Deserialize<'de>> Deserialize<'de> for NullableVec<T> {
fn deserialize<D>(de: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Self(null_as_empty(de)?))
}
}
/// The current reactions on an issue or PR itself (not a comment) — each
/// entry's `content` is Forgejo's shortcode (`"+1"`, `"heart"`, …, the
/// same vocabulary GitHub uses), `user` its login. Same shared-index
/// rationale as [`dependency_summaries`]: PRs are issues internally, so
/// `issue`/`pr`/`view` and the `reaction` verb's own listing all call
/// this instead of duplicating the fetch-and-shape step.
///
/// # Errors
///
/// Propagates the forge API errors from listing reactions.
pub(crate) fn issue_reactions(
client: &Client,
owner: &str,
name: &str,
number: u64,
) -> Result<Vec<Value>> {
let reactions: NullableVec<Reaction> = client.get_api_json(
&format!("/repos/{owner}/{name}/issues/{number}/reactions"),
&[],
)?;
Ok(reaction_values(reactions.0))
}
/// The current reactions on a single comment, by comment id (not the
/// parent issue/PR number — Forgejo's comment-reaction endpoints are
/// keyed on the comment alone, same as [`comment_show`]'s lookup).
///
/// # Errors
///
/// Propagates the forge API errors from listing reactions.
pub(crate) fn comment_reactions(
client: &Client,
owner: &str,
name: &str,
comment_id: u64,
) -> Result<Vec<Value>> {
let reactions: NullableVec<Reaction> = client.get_api_json(
&format!("/repos/{owner}/{name}/issues/comments/{comment_id}/reactions"),
&[],
)?;
Ok(reaction_values(reactions.0))
}
fn reaction_values(reactions: Vec<Reaction>) -> Vec<Value> {
reactions
.into_iter()
.map(|r| json!({ "content": r.content, "user": r.user.and_then(|u| u.login) }))
.collect()
}
/// A one-line `content×count` summary of a reaction list (e.g. `+1×2
/// heart×1`), grouped and sorted by content name for a stable rendering.
/// `None` for an empty list, so a row/section with no reactions omits the
/// summary entirely rather than printing something empty.
pub(crate) fn reaction_summary(reactions: &[Value]) -> Option<String> {
let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for r in reactions {
if let Some(content) = r.get("content").and_then(Value::as_str) {
*counts.entry(content).or_insert(0) += 1;
}
}
if counts.is_empty() {
return None;
}
Some(
counts
.into_iter()
.map(|(content, n)| format!("{content}×{n}"))
.collect::<Vec<_>>()
.join(" "),
)
}
/// A single attachment as one display line — `[file: <name>] <url>`,
/// mirroring the `[file: ...]` marker convention `read_room` already
/// uses for matrix attachments. `None` when the attachment has no
/// download URL (shouldn't happen server-side, but a missing pointer
/// is worse silently dropped than shown as "?").
///
/// Forgejo already returns `assets` inline on the same `Comment`/`Issue`
/// fetch every render path here already makes — this just reads a field
/// that was sitting unused, the gap that made an attachment link
/// unreadable from a non-visual CLI read without guessing the UUID by
/// hand (hit in practice on the swarm-controller extraction thread).
pub(crate) fn attachment_line(a: &Attachment) -> Option<String> {
let name = a.name.as_deref().unwrap_or("?");
let url = a.browser_download_url.as_ref()?;
Some(format!("[file: {name}] {url}"))
}
/// JSON form of an attachment list (`{"name", "url"}` per entry), for
/// `--json` output — same data [`attachment_line`] renders as text.
pub(crate) fn attachment_json(assets: Option<&[Attachment]>) -> Vec<Value> {
assets
.unwrap_or_default()
.iter()
.map(|a| {
json!({
"name": a.name,
"url": a.browser_download_url.as_ref().map(ToString::to_string),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{NullableVec, pct_encode, reviewed_older_head};
#[test]
fn nullable_vec_treats_a_json_null_as_empty() {
// The exact shape of the real incident this guards: Forgejo answers
// a reactions (or combined-status) list with a bare `null` body
// when the collection is empty, not `[]` — deserializing straight
// into `Vec<T>` fails on it, `NullableVec<T>` must not.
let v: NullableVec<i64> = serde_json::from_str("null").unwrap();
assert_eq!(v.0, Vec::<i64>::new());
}
#[test]
fn nullable_vec_passes_a_real_array_through() {
let v: NullableVec<i64> = serde_json::from_str("[1,2,3]").unwrap();
assert_eq!(v.0, vec![1, 2, 3]);
}
#[test]
fn review_on_an_older_commit_is_stale() {
// The bug this exists for: forgejo still reports `stale: false` here
// in the seconds after a push, so the comparison has to catch it.
assert!(reviewed_older_head(Some("81292f14"), Some("f4c47088")));
}
#[test]
fn review_on_the_current_head_is_not_stale() {
assert!(!reviewed_older_head(Some("f4c47088"), Some("f4c47088")));
}
/// Every unknown fails *toward* keeping the verdict. Voiding every review
/// on a forge that stopped reporting one of these would be a far louder
/// wrong answer than one redundant re-review.
#[test]
fn unknown_commit_or_head_is_not_stale() {
assert!(!reviewed_older_head(None, Some("f4c47088")));
assert!(!reviewed_older_head(Some("81292f14"), None));
assert!(!reviewed_older_head(Some(""), Some("f4c47088")));
// Absent and blank have to behave the same on BOTH sides — a blank
// head that counted as real would mark every review on the PR stale.
assert!(!reviewed_older_head(Some("81292f14"), Some("")));
assert!(!reviewed_older_head(Some(""), Some("")));
}
#[test]
fn pct_encode_passes_unreserved_through() {
// Plain artifact names round-trip verbatim — no performance
// regression on the common case.
assert_eq!(pct_encode("damocles"), "damocles");
assert_eq!(pct_encode("area-ops"), "area-ops");
assert_eq!(pct_encode("area_ops"), "area_ops");
assert_eq!(pct_encode("pr1ma-paper-pdf"), "pr1ma-paper-pdf");
}
#[test]
fn pct_encode_escapes_reserved() {
// `&` / `/` / spaces in any spliced value must escape so they
// can't break out of the path/query segment.
assert_eq!(pct_encode("good first issue"), "good%20first%20issue");
assert_eq!(pct_encode("x&y"), "x%26y");
assert_eq!(pct_encode("a/b"), "a%2Fb");
}
#[test]
fn nearest_finds_the_one_character_typo() {
let all = ["area/ops", "area/broker", "type/bug"];
assert_eq!(super::nearest("area/opps", &all), Some("area/ops"));
assert_eq!(super::nearest("type/bugs", &all), Some("type/bug"));
}
#[test]
fn nearest_suggests_nothing_for_an_invention() {
// The important half: a wrong suggestion invites a second failed
// attempt at a name that was never there, so far-away input must
// fall back to "here is everything".
let all = ["area/ops", "area/broker", "type/bug"];
assert_eq!(super::nearest("frontend", &all), None);
assert_eq!(super::nearest("", &all), None);
}
#[test]
fn nearest_is_stable_when_two_candidates_tie() {
// Both are distance 1 from "v3"; the answer must not depend on
// the order the forge returned them in.
let forward = ["v1", "v2"];
let reversed = ["v2", "v1"];
assert_eq!(
super::nearest("v3", &forward),
super::nearest("v3", &reversed)
);
}
#[test]
fn with_suggestions_annotates_only_the_near_misses() {
let all = ["area/ops", "type/bug"];
let rendered = super::with_suggestions(&["area/opps", "frontend"], &all);
assert!(
rendered.contains(r#"area/opps (did you mean "area/ops"?)"#),
"{rendered}"
);
assert!(rendered.contains("frontend"), "{rendered}");
assert!(
!rendered.contains(r"frontend (did you mean"),
"invented name must not get a suggestion: {rendered}"
);
}
}