hyperhive/hive-forge/src/client.rs

660 lines
27 KiB
Rust

//! App-level Forgejo client wrapper. Identity is the per-agent token
//! under `${HYPERHIVE_STATE_DIR}/forge-token`. REST calls go through
//! the typed [`forgejo_api::sync::Forgejo`] client (exposed via
//! [`Client::api`]); a minimal raw `reqwest` client remains for the
//! few *web-router* routes Forgejo does not serve under `/api/v1/`
//! (attachment downloads, Actions artifact/log routes).
use std::path::PathBuf;
use anyhow::{Context, Result, anyhow, bail};
use forgejo_api::{Auth, ForgejoError};
use reqwest::blocking::{Client as HttpClient, Response};
use reqwest::header::{
ACCEPT, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderMap, HeaderValue,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Default Forgejo URL when `HIVE_FORGE_URL` is unset.
const DEFAULT_URL: &str = "http://localhost:3000";
/// Forgejo client pair: the typed `/api/v1` client plus a raw
/// `reqwest` client for web-router-only routes.
pub struct Client {
/// Typed Forgejo REST client — every `/api/v1` call goes through
/// this (see [`Client::api`]).
api: forgejo_api::sync::Forgejo,
/// Raw HTTP client for the routes Forgejo serves only through its
/// web router, NOT under `/api/v1/` (so `forgejo-api` has no
/// method for them): attachment downloads at `/attachments/<uuid>`,
/// Actions artifact zips, the run-view log streamer, and the
/// persisted-log download. Carries the same `Authorization: token`
/// header the typed client sends.
web: HttpClient,
base: String,
/// The `-f/--forge` label this client resolved against, `None` for
/// the default internal forge. `clone` bakes this back into the
/// `credential.helper` command it configures, so a later `git push`/
/// `git fetch` in that checkout re-resolves the *same* forge account
/// rather than silently falling back to the internal one.
forge_label: Option<String>,
/// Default repo used when a verb doesn't carry an explicit
/// `[repo]` override. `None` when nothing resolved — resolution is
/// deferred to [`Client::repo`] rather than failing here, so a
/// repo-independent verb (`repo-search`, `repo-create`) never needs
/// one to exist.
default_repo: Option<String>,
/// Global `--json` flag — verbs that have a human-readable
/// default path branch on `client.json_mode()` to pick the
/// JSON output shape instead.
json_mode: bool,
}
impl Client {
/// Build a client from the standard environment variables.
///
/// Repo resolution, highest priority first: the global `-r/--repo`
/// flag (`repo_override`); the `origin` remote of the git checkout
/// containing the current working directory (see
/// [`infer_repo_from_cwd`]); the `HIVE_FORGE_REPO` env var (kept as
/// a last-resort override for callers that aren't in any checkout —
/// nothing in this hive's nix config sets it, so it's opt-in only).
/// No repo resolving from any of those is **not** an error here — a
/// single hardcoded fallback repo was exactly what made agents
/// assume it was "the only repo they're allowed to operate on", and
/// eagerly failing here made every verb inherit a requirement it
/// might not have. It's an error only when a repo-scoped verb
/// actually asks for one — see [`Client::repo`].
///
/// `json_mode` comes from the global `--json` flag — per-verb
/// output formatters key off it via `Client::json_mode`.
/// `forge_label` (from the global `-f/--forge` flag) targets a
/// dashboard-provisioned external forge account instead of the
/// internal forge — see [`resolve_credentials`].
pub fn from_env(
repo_override: Option<String>,
json_mode: bool,
forge_label: Option<String>,
) -> Result<Self> {
let (base, token) = resolve_credentials(forge_label.as_deref())?;
let default_repo = repo_override
.or_else(infer_repo_from_cwd)
.or_else(|| std::env::var("HIVE_FORGE_REPO").ok());
let url = url::Url::parse(&base).with_context(|| format!("parse HIVE_FORGE_URL {base}"))?;
let api = forgejo_api::sync::Forgejo::new(Auth::Token(&token), url)
.context("build forgejo client")?;
let mut headers = HeaderMap::new();
let auth = format!("token {token}");
let mut auth_val = HeaderValue::from_str(&auth).context("auth header")?;
auth_val.set_sensitive(true);
headers.insert(AUTHORIZATION, auth_val);
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
let web = HttpClient::builder()
.default_headers(headers)
.build()
.context("build reqwest client")?;
Ok(Self {
api,
web,
base,
forge_label,
default_repo,
json_mode,
})
}
/// The typed Forgejo REST client. All `/api/v1` traffic goes
/// through this.
#[must_use]
pub fn api(&self) -> &forgejo_api::sync::Forgejo {
&self.api
}
/// The *credential-free* git URL for `repo` — no user/token in the
/// authority, so nothing durable lands in `.git/config` when this is
/// the URL `git clone` is given. Pairs with the `credential-helper`
/// verb, which `clone` configures as the repo's `credential.helper`
/// so git asks for (and gets) the token fresh from its file on every
/// fetch/push instead of it being embedded here. Replaces the old
/// `authed_git_url` (`http://<user>:<token>@host/<repo>.git`), which
/// left a durable token in every checkout's `.git/config` — a real
/// leak reported by atlas.
#[must_use]
pub fn plain_git_url(&self, repo: &str) -> String {
format!("{}/{repo}.git", self.base)
}
/// The `-f/--forge` label this client resolved against (`None` for
/// the internal forge). See the `forge_label` field doc for why
/// `clone` needs this.
#[must_use]
pub fn forge_label(&self) -> Option<&str> {
self.forge_label.as_deref()
}
/// True when the operator passed the global `--json` flag.
/// Verbs that have a human-readable default branch on this to
/// emit JSON instead. Verbs whose only output format is JSON
/// (e.g. `issue`, `pr`) can ignore it.
#[must_use]
pub fn json_mode(&self) -> bool {
self.json_mode
}
/// Return the active repo. `from_env` already folded the
/// `-r/--repo` override into `default_repo`, so verbs just read
/// it as-is — no per-verb override plumbing.
///
/// # Errors
///
/// Errors when nothing resolved a repo (see `from_env`'s doc). Only
/// a verb that actually calls this (directly or via
/// [`Client::owner_repo`]) can fail this way — `repo-search` and
/// `repo-create` never do, so they run with no repo at all.
pub fn repo(&self) -> Result<&str> {
self.default_repo.as_deref().ok_or_else(|| {
anyhow!(
"hive-forge: no repo specified — pass -r/--repo, run from inside a \
git checkout with an `origin` remote, or set HIVE_FORGE_REPO"
)
})
}
/// The active repo split into `(owner, name)` for the typed
/// client's per-segment path arguments.
pub fn owner_repo(&self) -> Result<(&str, &str)> {
split_repo(self.repo()?)
}
/// Build the full URL for a Forgejo attachment by UUID.
/// Attachments live at `<base>/attachments/<uuid>`, NOT under
/// `/api/v1/`, so this uses `self.base` directly.
#[must_use]
pub fn attachment_url(&self, uuid: &str) -> String {
format!("{}/attachments/{uuid}", self.base)
}
/// Build a full URL for a base-relative *web* path (i.e. NOT under
/// `/api/v1/`). Used for endpoints Forgejo only serves through its
/// web UI rather than the REST API --- e.g. Actions artifact
/// downloads at `<base>/<owner>/<repo>/actions/runs/<n>/artifacts/<name>`.
/// `path` should start with `/`.
#[must_use]
pub fn web_url(&self, path: &str) -> String {
format!("{}{path}", self.base)
}
/// POST a JSON body to a base-relative *web* path (NOT under
/// `/api/v1/`, so no `forgejo-api` method exists) and decode the
/// JSON response. Used for endpoints Forgejo only serves through
/// its web router — e.g. the Actions run-view log streamer at
/// `<base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>`. The
/// agent's `Authorization: token` header is sent as usual; the
/// web router accepts a token-authed doer and, because the handler
/// consumes a JSON body rather than a CSRF-bound HTML form, no
/// `_csrf` token is required (same auth path `get_bytes_raw` uses
/// for the web artifact-download route). `path` should start `/`.
pub fn post_json_web<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
let url = self.web_url(path);
let resp = self
.web
.post(&url)
.header(CONTENT_TYPE, "application/json")
.json(body)
.send()
.context("POST")?;
let resp = check_status(resp, &format!("POST {url}"))?;
resp.json::<Value>()
.with_context(|| format!("decode JSON for POST {url}"))
}
/// GET a raw (non-API) URL and return the response body as bytes.
/// Stays on the raw `reqwest` client because these are *web-router*
/// routes (`/attachments/<uuid>`, Actions artifact / persisted-log
/// downloads) with no `/api/v1` equivalent. The client's auth
/// headers are still sent — Forgejo requires them for private
/// attachment downloads. Uses the full URL as-is; the caller is
/// responsible for constructing it (see `attachment_url`).
pub fn get_bytes_raw(&self, url: &str) -> Result<Vec<u8>> {
self.get_bytes_named(url).map(|(bytes, _)| bytes)
}
/// Like [`Client::get_bytes_raw`], but also returns the filename the
/// server put in `Content-Disposition`.
///
/// Forgejo's persisted Actions-log route names the download after the
/// job it actually served, and that header is the **only** part of the
/// response identifying which job came back: the route clamps an
/// out-of-range job index to job 0 and answers 200, so the body alone
/// is byte-identical to a legitimate read.
///
/// # Errors
/// Same as [`Client::get_bytes_raw`] — transport failure or a non-2xx.
pub fn get_bytes_named(&self, url: &str) -> Result<(Vec<u8>, Option<String>)> {
let resp = self.web.get(url).send().context("GET")?;
let resp = check_status(resp, &format!("GET {url}"))?;
let name = resp
.headers()
.get(CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(disposition_filename);
resp.bytes()
.map(|b| (b.to_vec(), name))
.with_context(|| format!("read bytes for GET {url}"))
}
/// GET a JSON `/api/v1` route and deserialize into `T`, bypassing the
/// typed `forgejo-api` client. Use this where the typed client's
/// structs are too strict against the running Forgejo version: the
/// crate pins one schema, but the server tracks the latest release
/// line, so a drifted field breaks deserialization for the whole
/// call. Callers pass a *lenient* local struct (only the fields they
/// use, all `#[serde(default)]`) so a schema change can't wedge the
/// call. `path` starts with `/` and is relative to `/api/v1`; `query`
/// is appended as URL query parameters.
///
/// # Errors
/// Returns an error on transport failure, a non-2xx response (body
/// included), or if the response body doesn't deserialize into `T`.
pub fn get_api_json<T: serde::de::DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T> {
let base = format!("{}/api/v1{path}", self.base);
let url = url::Url::parse_with_params(&base, query.iter().copied())
.with_context(|| format!("build url {base}"))?;
let resp = self.web.get(url.clone()).send().context("GET")?;
let resp = check_status(resp, &format!("GET {url}"))?;
resp.json::<T>()
.with_context(|| format!("decode JSON for GET {url}"))
}
}
/// Infer `owner/name` from the `origin` remote of the git checkout
/// containing the current working directory. Shells out to
/// `git remote get-url origin` rather than hand-parsing `.git/config` —
/// git already knows how to find the enclosing repo from any
/// subdirectory, honours `include`/worktrees/whatever config shape is
/// actually on disk, and is one process spawn cheaper to trust than a
/// bespoke parser. Returns `None` (never an error) on any failure — cwd
/// isn't inside a repo, no `origin` remote, `git` isn't on `PATH`, or
/// the URL doesn't parse into `owner/name` — so callers fall through to
/// the next resolution step.
fn infer_repo_from_cwd() -> Option<String> {
let output = std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8(output.stdout).ok()?;
owner_repo_from_git_url(url.trim())
}
/// Extract `owner/name` from a git remote URL. Handles the shapes this
/// hive's remotes actually take: `http(s)://[user[:token]@]host/owner/name[.git]`.
/// Strips the scheme, then any embedded credentials (up to the last
/// `@`), then the host, then a trailing `.git`; the remaining path must
/// be exactly two non-empty segments. Anything else (SSH `git@host:` /
/// `ssh://` remotes, nested paths, malformed URLs) returns `None` —
/// this hive's forge only ever serves flat `owner/name` repos over
/// HTTP, so there's nothing else to support.
fn owner_repo_from_git_url(url: &str) -> Option<String> {
let without_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
let without_creds = without_scheme
.rsplit_once('@')
.map_or(without_scheme, |(_, rest)| rest);
let (_host, path) = without_creds.split_once('/')?;
let path = path.strip_suffix(".git").unwrap_or(path);
let mut segments = path.split('/').filter(|s| !s.is_empty());
let owner = segments.next()?;
let name = segments.next()?;
if segments.next().is_some() {
return None;
}
Some(format!("{owner}/{name}"))
}
/// Split an `owner/name` repo string into its two path segments for
/// the typed client. Errors on anything that isn't exactly
/// `owner/name` with both halves non-empty.
pub fn split_repo(repo: &str) -> Result<(&str, &str)> {
match repo.split_once('/') {
Some((owner, name)) if !owner.is_empty() && !name.is_empty() && !name.contains('/') => {
Ok((owner, name))
}
_ => bail!("hive-forge: repo must be of the form owner/name, got {repo:?}"),
}
}
/// True when a typed-client error is Forgejo saying 404 (resource
/// absent rather than a transport / auth / server failure). Used by
/// verbs that treat "not there" as a normal state (e.g.
/// `subscription` reads a 404 as "not watching").
#[must_use]
pub fn is_not_found(err: &ForgejoError) -> bool {
match err {
ForgejoError::ApiError(e) => {
matches!(e.error_kind(), forgejo_api::ApiErrorKind::NotFound { .. })
}
ForgejoError::UnexpectedStatusCode(code) => *code == reqwest::StatusCode::NOT_FOUND,
_ => false,
}
}
/// Convert a CLI-side `u64` issue/PR/comment number to the `i64` the
/// typed client's path arguments use. Numbers past `i64::MAX` don't
/// exist on any forge; error instead of wrapping.
pub fn index(n: u64) -> Result<i64> {
i64::try_from(n).with_context(|| format!("number {n} out of range"))
}
/// Resolve the `(base_url, token)` pair the client authenticates with.
/// `None` (the default) resolves the internal forge exactly as before:
/// `HIVE_FORGE_URL` (default [`DEFAULT_URL`]) + `read_token()`.
/// `Some(label)` (from `-f/--forge <label>`) instead resolves a
/// dashboard-provisioned external forge account: the token comes from
/// `${HYPERHIVE_STATE_DIR}/forge-<label>-token` and the base URL from
/// the `base_url` key of the sibling `forge-<label>.json` sidecar —
/// the exact same two files `dashboard/extra_forges.rs` writes, so the
/// read side can't drift from the write side. An unknown label (either
/// file missing) is a clear error listing the labels actually found in
/// the state dir, not a raw file-not-found. A label outside the plain
/// identifier charset the dashboard accepts (e.g. a typo containing
/// `/` or `..`) is rejected up front with the same charset spelled out,
/// rather than silently building a nonsense/traversing path and
/// surfacing a confusing file error later.
pub(crate) fn resolve_credentials(forge_label: Option<&str>) -> Result<(String, String)> {
let Some(label) = forge_label else {
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
let token = read_token().context("read forge-token")?;
return Ok((base, token));
};
if !is_plain_ident(label) {
bail!(
"hive-forge: invalid --forge label {label:?} — must be lowercase \
letters, digits, and hyphens only (same rule the dashboard's \
FORGES tab enforces)"
);
}
let dir = state_dir();
let token_path = dir.join(format!("forge-{label}-token"));
let sidecar_path = dir.join(format!("forge-{label}.json"));
let token = std::fs::read_to_string(&token_path).map(|s| s.trim().to_owned());
let sidecar = std::fs::read_to_string(&sidecar_path)
.ok()
.and_then(|s| serde_json::from_str::<ForgeSidecar>(&s).ok());
if let (Ok(token), Some(sidecar)) = (token, sidecar) {
return Ok((sidecar.base_url, token));
}
let known = provisioned_labels(&dir);
let known = if known.is_empty() {
"(none provisioned)".to_owned()
} else {
known.join(", ")
};
bail!("hive-forge: no such forge {label:?} — provisioned forges: {known}");
}
/// Plain-identifier check matching `dashboard/extra_forges.rs`'s
/// `is_plain_ident` (itself matching hive-priv's `validate_name_chars`)
/// — lowercase ascii + digits + hyphens only. Rejecting anything else
/// up front (rather than just letting a weird label fail to resolve a
/// file) turns a confusing "no such forge" surprise into a precise
/// "that's not a valid label" one, and incidentally means a label like
/// `../../etc` can't be used to build a path outside the state dir.
fn is_plain_ident(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
/// Sidecar shape `dashboard/extra_forges.rs` writes alongside each
/// `forge-<label>-token` file: just the base URL, pinned to the same
/// `base_url` JSON key the write side uses.
#[derive(Deserialize)]
struct ForgeSidecar {
base_url: String,
}
/// Scan the state dir for every `forge-<label>-token` file (mirroring
/// `dashboard/extra_forges.rs`'s own listing logic) and return the
/// labels found, sorted. Used to build a helpful "did you mean one of
/// these" error when `--forge <label>` doesn't resolve. Best-effort:
/// an unreadable state dir yields an empty list rather than erroring
/// (the caller already has its own error to report).
fn provisioned_labels(dir: &std::path::Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut labels: Vec<String> = entries
.flatten()
.filter(|e| e.file_type().is_ok_and(|ft| ft.is_file()))
.filter_map(|e| {
e.file_name()
.to_str()?
.strip_prefix("forge-")?
.strip_suffix("-token")
.filter(|label| !label.is_empty())
.map(str::to_owned)
})
.collect();
labels.sort();
labels
}
/// The configured `HYPERHIVE_STATE_DIR`, falling back to `$PWD` when
/// unset — matches `read_token`'s fallback for the plain internal-forge
/// path.
fn state_dir() -> PathBuf {
match std::env::var("HYPERHIVE_STATE_DIR") {
Ok(s) if !s.is_empty() => PathBuf::from(s),
_ => PathBuf::from("."),
}
}
/// Locate and read the forge token. Falls back to `$PWD/forge-token`
/// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper.
fn read_token() -> Result<String> {
let path = state_dir().join("forge-token");
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("hive-forge: no forge-token at {}", path.display()))?;
Ok(raw.trim().to_owned())
}
/// The quoted `filename="…"` out of a `Content-Disposition` value.
///
/// Deliberately reads the plain `filename=` parameter and not the RFC 5987
/// `filename*=UTF-8''…` one that Forgejo sends alongside it: the starred
/// form is percent-encoded, so it needs decoding before it can be shown,
/// and the plain form carries the same name.
fn disposition_filename(value: &str) -> Option<String> {
let after = value.split_once("filename=\"")?.1;
let (name, _) = after.split_once('"')?;
(!name.is_empty()).then(|| name.to_owned())
}
/// A non-2xx HTTP response from one of the raw *web-router* calls
/// (`post_json_web` / `get_bytes_raw` / `get_api_json`), carrying the
/// status code and body as data rather than baking them into a
/// formatted message. Without this, a caller that needs to tell "gone"
/// (404, or a 500 wrapping "resource does not exist") apart from "not
/// permitted" (401/403) has no honest way to do it — the status is read
/// once by [`check_status`] and then discarded into prose. A caller
/// that needs the code back gets it with
/// `err.downcast_ref::<WebStatusError>()` on the `anyhow::Error`
/// `post_json_web` et al return; `None` means the failure wasn't a
/// non-2xx response at all (transport, JSON decode, ...), so still
/// fall through to a generic message rather than assuming success.
#[derive(Debug)]
pub struct WebStatusError {
pub op: String,
pub status: reqwest::StatusCode,
pub body: String,
}
impl std::fmt::Display for WebStatusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"hive-forge: {} failed ({}): {}",
self.op, self.status, self.body
)
}
}
impl std::error::Error for WebStatusError {}
/// Surface non-2xx HTTP responses on the raw web routes as a
/// [`WebStatusError`] with the response body included (matches `curl
/// --fail-with-body`) — turns silent failures into errors with a
/// clear message, while keeping the status code available structurally
/// instead of only as formatted text.
fn check_status(resp: Response, op: &str) -> Result<Response> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let body = resp.text().unwrap_or_default();
Err(WebStatusError {
op: op.to_owned(),
status,
body,
}
.into())
}
#[cfg(test)]
mod tests {
use super::{WebStatusError, disposition_filename, index, owner_repo_from_git_url, split_repo};
#[test]
fn disposition_filename_reads_forgejos_real_header() {
// Verbatim from a persisted Actions-log download, both parameters
// present in the order Forgejo emits them.
let v = "attachment; filename=\"ci-doc-pointer lint-8363.log\"; \
filename*=UTF-8''ci-doc-pointer%20lint-8363.log";
assert_eq!(
disposition_filename(v).as_deref(),
Some("ci-doc-pointer lint-8363.log")
);
}
#[test]
fn disposition_filename_absent_or_unusable() {
assert_eq!(disposition_filename("attachment"), None);
assert_eq!(disposition_filename(""), None);
// Only the starred form: not decoded here, so it must not be
// mistaken for a usable plain filename.
assert_eq!(
disposition_filename("attachment; filename*=UTF-8''a%20b.log"),
None
);
// Unterminated quote — must not return a truncated name.
assert_eq!(disposition_filename("attachment; filename=\"oops"), None);
// Present but empty.
assert_eq!(disposition_filename("attachment; filename=\"\""), None);
}
#[test]
fn web_status_error_downcasts_to_recover_the_status_code() {
let err: anyhow::Error = WebStatusError {
op: "GET https://example.invalid/x".to_owned(),
status: reqwest::StatusCode::NOT_FOUND,
body: "resource does not exist".to_owned(),
}
.into();
let status = err.downcast_ref::<WebStatusError>().map(|e| e.status);
assert_eq!(status, Some(reqwest::StatusCode::NOT_FOUND));
}
#[test]
fn unrelated_errors_do_not_downcast_to_web_status_error() {
let err = anyhow::anyhow!("connection refused");
assert!(err.downcast_ref::<WebStatusError>().is_none());
}
#[test]
fn owner_repo_from_git_url_strips_scheme_and_creds() {
assert_eq!(
owner_repo_from_git_url(
"http://damocles:abc123token@forge.pr1ma.darkest.space/hyperhive/hyperhive.git"
),
Some("hyperhive/hyperhive".to_owned())
);
}
#[test]
fn owner_repo_from_git_url_handles_plain_https_no_creds() {
assert_eq!(
owner_repo_from_git_url("https://forge.pr1ma.darkest.space/hyperhive/hive-claude.git"),
Some("hyperhive/hive-claude".to_owned())
);
}
#[test]
fn owner_repo_from_git_url_tolerates_missing_dot_git_suffix() {
assert_eq!(
owner_repo_from_git_url("https://forge.pr1ma.darkest.space/internal/knowledge"),
Some("internal/knowledge".to_owned())
);
}
#[test]
fn owner_repo_from_git_url_rejects_nested_paths() {
assert_eq!(
owner_repo_from_git_url("https://forge.example/a/b/c.git"),
None
);
}
#[test]
fn owner_repo_from_git_url_rejects_host_only() {
assert_eq!(owner_repo_from_git_url("https://forge.example"), None);
}
#[test]
fn split_repo_accepts_owner_name() {
assert_eq!(
split_repo("hyperhive/hyperhive").unwrap(),
("hyperhive", "hyperhive")
);
assert_eq!(
split_repo("internal/knowledge").unwrap(),
("internal", "knowledge")
);
}
#[test]
fn split_repo_rejects_malformed() {
assert!(split_repo("no-slash").is_err());
assert!(split_repo("/name").is_err());
assert!(split_repo("owner/").is_err());
assert!(split_repo("a/b/c").is_err());
assert!(split_repo("").is_err());
}
/// The doc comment promises "error instead of wrapping". A wrap
/// would not fail loudly — it would produce a *negative* index the
/// forge then looks up as some other issue, so the failure mode is a
/// wrong answer rather than an error.
#[test]
fn index_errors_out_of_range_rather_than_wrapping() {
assert_eq!(index(0).unwrap(), 0);
assert_eq!(index(3950).unwrap(), 3950);
// The largest number that still fits, and the first that does not.
let max = u64::try_from(i64::MAX).expect("i64::MAX is non-negative");
assert_eq!(index(max).unwrap(), i64::MAX);
assert!(index(max + 1).is_err());
assert!(index(u64::MAX).is_err());
}
}