rust: port GitHub and IRC clients, IRC formatting

This commit is contained in:
Andy Dixon
2026-10-11 16:21:08 +01:00
parent 38dff53485
commit d20d16e8c8
7 changed files with 2582 additions and 2 deletions
+211 -2
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
@@ -58,6 +64,12 @@ dependencies = [
"password-hash",
]
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -218,6 +230,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -333,6 +351,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "cipher"
version = "0.4.4"
@@ -387,6 +411,15 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
@@ -540,6 +573,17 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -706,6 +750,28 @@ dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -837,6 +903,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.4"
@@ -861,14 +937,18 @@ version = "0.1.0"
dependencies = [
"aes-gcm",
"argon2",
"base64",
"base64 0.22.1",
"chrono",
"keyring",
"rand",
"rusqlite",
"rustls",
"serde",
"serde_json",
"tiny_http",
"ureq",
"uuid",
"webpki-roots",
]
[[package]]
@@ -995,6 +1075,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1116,6 +1202,20 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.37.0"
@@ -1143,6 +1243,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -1296,6 +1431,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
@@ -1315,7 +1456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1365,6 +1506,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "tokio"
version = "1.53.2"
@@ -1475,6 +1628,47 @@ dependencies = [
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64 0.23.1",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
dependencies = [
"base64 0.23.1",
"http",
"httparse",
"log",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "uuid"
version = "1.28.0"
@@ -1549,6 +1743,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -1895,6 +2098,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
+6
View File
@@ -14,3 +14,9 @@ aes-gcm = "0.10"
rand = "0.8"
base64 = "0.22"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "tokio", "crypto-rust"] }
ureq = "3"
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
webpki-roots = "1"
[dev-dependencies]
tiny_http = "0.12"
+555
View File
@@ -0,0 +1,555 @@
//! Lightweight GitHub REST client for repository metadata and archive downloads. Works with
//! github.com and GitHub Enterprise via a configurable API base URL.
use std::io::{Read, Write};
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use chrono::{DateTime, FixedOffset, SecondsFormat};
use serde::Deserialize;
use crate::models::GitHubRepo;
const MAX_JSON_BODY_BYTES: u64 = 4 << 20;
const MAX_ERROR_BODY_BYTES: u64 = 64 << 10;
const MAX_ARCHIVE_BYTES: u64 = 2 << 30;
const USER_AGENT: &str = "NexusTerminal/1.0";
const COMMIT_LOOKUP_WORKERS: usize = 5;
pub struct Client {
base_url: String,
token: String,
agent: ureq::Agent,
}
#[derive(Deserialize)]
struct ApiRepo {
#[serde(default)]
full_name: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
html_url: Option<String>,
#[serde(default)]
default_branch: Option<String>,
#[serde(default)]
private: Option<bool>,
#[serde(default)]
fork: Option<bool>,
#[serde(default)]
archived: Option<bool>,
#[serde(default)]
language: Option<String>,
#[serde(default)]
stargazers_count: Option<i64>,
#[serde(default)]
pushed_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
#[serde(default)]
owner: Option<ApiOwner>,
}
#[derive(Deserialize)]
struct ApiOwner {
#[serde(default)]
login: Option<String>,
}
#[derive(Deserialize, Default)]
struct ApiPerson {
#[serde(default)]
name: Option<String>,
#[serde(default)]
date: Option<String>,
}
impl Client {
/// `base_url` is the API root, e.g. `https://api.github.com` or `https://ghe.example.com/api/v3`.
pub fn new(base_url: &str, token: &str) -> Client {
let agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(30)))
.http_status_as_error(false)
.build()
.into();
Client { base_url: base_url.trim_end_matches('/').to_string(), token: token.to_string(), agent }
}
fn get(&self, url: &str) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
self.agent
.get(url)
.header("Authorization", &format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", USER_AGENT)
.call()
}
/// Confirms the token works and returns the authenticated login.
pub fn validate_token(&self) -> crate::Result<String> {
let mut resp = self
.get(&format!("{}/user", self.base_url))
.map_err(|e| format!("contacting GitHub API: {e}"))?;
let status = resp.status().as_u16();
if status != 200 {
return Err(format!("token validation failed (HTTP {status})").into());
}
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES)
.map_err(|e| format!("reading user response: {e}"))?;
#[derive(Deserialize)]
struct User {
#[serde(default)]
login: Option<String>,
}
let user: User = serde_json::from_slice(&body).map_err(|e| format!("decoding user: {e}"))?;
Ok(user.login.unwrap_or_default())
}
/// All repositories visible to the token, newest push first, enriched with latest-commit info.
pub fn fetch_repos(&self) -> crate::Result<Vec<GitHubRepo>> {
self.fetch_repos_with_progress(|_, _| {})
}
/// Like `fetch_repos`, reporting commit-enrichment progress as `(done, total)`; it is called
/// from worker threads.
pub fn fetch_repos_with_progress(
&self,
progress: impl Fn(usize, usize) + Sync,
) -> crate::Result<Vec<GitHubRepo>> {
let mut all: Vec<ApiRepo> = Vec::new();
let mut page = 1;
loop {
let url = format!(
"{}/user/repos?per_page=100&page={page}&sort=pushed&direction=desc",
self.base_url
);
let mut resp = self.get(&url).map_err(|e| format!("fetching repos page {page}: {e}"))?;
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES)
.map_err(|e| format!("reading response body: {e}"))?;
let status = resp.status().as_u16();
if status == 401 || status == 403 {
return Err(
format!("authentication failed (HTTP {status}) — check your access token").into()
);
}
if status != 200 {
return Err(format!(
"GitHub API returned HTTP {status}: {}",
String::from_utf8_lossy(&body)
)
.into());
}
let batch: Vec<ApiRepo> =
serde_json::from_slice(&body).map_err(|e| format!("decoding repos: {e}"))?;
if batch.is_empty() {
break;
}
let short = batch.len() < 100;
all.extend(batch);
// Safety cap — 5000 repos should be more than enough.
if all.len() >= 5000 || short {
break;
}
page += 1;
}
let mut parsed = Vec::with_capacity(all.len());
for r in all {
let pushed = parse_time(r.pushed_at.as_deref()).map_err(|e| format!("decoding repos: {e}"))?;
let updated = parse_time(r.updated_at.as_deref()).map_err(|e| format!("decoding repos: {e}"))?;
parsed.push((r, pushed, updated));
}
parsed.sort_by(|a, b| b.1.cmp(&a.1));
let mut result: Vec<GitHubRepo> = parsed
.into_iter()
.map(|(r, pushed, updated)| GitHubRepo {
full_name: r.full_name.unwrap_or_default(),
name: r.name.unwrap_or_default(),
owner: r.owner.and_then(|o| o.login).unwrap_or_default(),
description: r.description.unwrap_or_default(),
html_url: r.html_url.unwrap_or_default(),
default_branch: r.default_branch.unwrap_or_default(),
private: r.private.unwrap_or_default(),
fork: r.fork.unwrap_or_default(),
archived: r.archived.unwrap_or_default(),
language: r.language.unwrap_or_default(),
stars: r.stargazers_count.unwrap_or_default(),
pushed_at: format_time(pushed),
updated_at: format_time(updated),
..Default::default()
})
.collect();
let total = result.len();
progress(0, total);
// Enrich with latest commit metadata (best-effort), five lookups at a time.
let next = AtomicUsize::new(0);
let done = AtomicUsize::new(0);
let lookups: Vec<(usize, String, String)> = result
.iter()
.enumerate()
.map(|(i, r)| {
let reference = if r.default_branch.is_empty() { "HEAD" } else { &r.default_branch };
(i, r.full_name.clone(), reference.to_string())
})
.collect();
let found: Vec<(usize, String, String)> = std::thread::scope(|s| {
let workers: Vec<_> = (0..COMMIT_LOOKUP_WORKERS.min(total))
.map(|_| {
s.spawn(|| {
let mut out = Vec::new();
loop {
let i = next.fetch_add(1, Ordering::SeqCst);
let Some((idx, name, reference)) = lookups.get(i) else { break };
if let Ok((who, when)) = self.fetch_latest_commit(name, reference) {
out.push((*idx, who, when));
}
progress(done.fetch_add(1, Ordering::SeqCst) + 1, total);
}
out
})
})
.collect();
workers.into_iter().flat_map(|w| w.join().unwrap_or_default()).collect()
});
for (idx, who, when) in found {
result[idx].latest_committer = who;
result[idx].latest_commit_at = when;
}
Ok(result)
}
/// Committer name and RFC 3339 commit time for a repo and ref (`HEAD` when empty).
pub fn fetch_latest_commit(&self, full_name: &str, reference: &str) -> crate::Result<(String, String)> {
let reference = if reference.is_empty() { "HEAD" } else { reference };
let (owner, repo) = split_repo_full_name(full_name)?;
let url = self.endpoint_url(&["repos", &owner, &repo, "commits", reference])?;
let mut resp = self.get(&url).map_err(|e| format!("fetching commit: {e}"))?;
let status = resp.status().as_u16();
if status == 401 || status == 403 {
return Err(format!("authentication failed (HTTP {status})").into());
}
if status != 200 {
return Err(format!(
"GitHub API returned HTTP {status}: {}",
read_error_body(resp.body_mut().as_reader())
)
.into());
}
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES)
.map_err(|e| format!("reading commit response: {e}"))?;
#[derive(Deserialize)]
struct Payload {
#[serde(default)]
commit: Commit,
}
#[derive(Deserialize, Default)]
struct Commit {
#[serde(default)]
committer: Option<ApiPerson>,
#[serde(default)]
author: Option<ApiPerson>,
}
let payload: Payload = serde_json::from_slice(&body).map_err(|e| format!("decoding commit: {e}"))?;
let committer = payload.commit.committer.unwrap_or_default();
let author = payload.commit.author.unwrap_or_default();
let (mut name, mut date) = (committer.name.unwrap_or_default(), committer.date);
if name.is_empty() {
name = author.name.unwrap_or_default();
date = author.date;
}
let date = parse_time(date.as_deref()).map_err(|e| format!("decoding commit: {e}"))?;
match date {
Some(d) if !name.is_empty() => Ok((name, format_time(Some(d)))),
_ => Err("commit metadata missing".into()),
}
}
/// Downloads the repository zipball (default branch when `reference` is empty) to `dest_path`.
pub fn download_repo_archive(&self, full_name: &str, reference: &str, dest_path: &Path) -> crate::Result<()> {
let (owner, repo) = split_repo_full_name(full_name)?;
let mut segments = vec!["repos", &owner, &repo, "zipball"];
if !reference.is_empty() {
segments.push(reference);
}
let url = self.endpoint_url(&segments)?;
let mut resp = self.get(&url).map_err(|e| format!("downloading archive: {e}"))?;
let status = resp.status().as_u16();
if status == 401 || status == 403 {
return Err(format!("authentication failed (HTTP {status}) — check your access token").into());
}
if status != 200 {
return Err(format!(
"GitHub API returned HTTP {status}: {}",
read_error_body(resp.body_mut().as_reader())
)
.into());
}
let length = resp
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<u64>().ok());
if let Some(length) = length.filter(|&l| l > MAX_ARCHIVE_BYTES) {
return Err(
format!("archive is too large: {length} bytes exceeds {MAX_ARCHIVE_BYTES} bytes").into()
);
}
let mut file = std::fs::File::create(dest_path).map_err(|e| format!("creating file: {e}"))?;
copy_limited_archive(&mut file, resp.body_mut().as_reader(), MAX_ARCHIVE_BYTES)
.map_err(|e| format!("writing archive: {e}"))?;
Ok(())
}
/// Base URL plus path-escaped segments.
fn endpoint_url(&self, segments: &[&str]) -> crate::Result<String> {
let mut url = self.base_url.clone();
for segment in segments {
if segment.is_empty() {
return Err("empty GitHub API path segment".into());
}
url.push('/');
url.push_str(&path_escape(segment));
}
Ok(url)
}
}
/// Go's `url.PathEscape`.
fn path_escape(segment: &str) -> String {
let mut out = String::new();
for b in segment.bytes() {
if b.is_ascii_alphanumeric() || b"-_.~$&+:=@".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
fn parse_time(value: Option<&str>) -> Result<Option<DateTime<FixedOffset>>, String> {
match value {
None => Ok(None),
Some(v) => {
let t = DateTime::parse_from_rfc3339(v).map_err(|e| format!("parsing time {v:?}: {e}"))?;
// Go's zero time means "unset".
Ok(Some(t).filter(|t| t.timestamp() != -62135596800))
}
}
}
/// Go's `time.Time.Format(time.RFC3339)`, with the zero time for a missing value.
fn format_time(value: Option<DateTime<FixedOffset>>) -> String {
match value {
Some(t) => t.to_rfc3339_opts(SecondsFormat::Secs, true),
None => "0001-01-01T00:00:00Z".to_string(),
}
}
fn read_limited(r: impl Read, max_bytes: u64) -> crate::Result<Vec<u8>> {
let mut body = Vec::new();
r.take(max_bytes + 1).read_to_end(&mut body)?;
if body.len() as u64 > max_bytes {
return Err(format!("response body exceeds {max_bytes} bytes").into());
}
Ok(body)
}
fn read_error_body(r: impl Read) -> String {
match read_limited(r, MAX_ERROR_BODY_BYTES) {
Ok(body) => String::from_utf8_lossy(&body).trim().to_string(),
Err(e) => format!("<response body unavailable: {e}>"),
}
}
fn split_repo_full_name(full_name: &str) -> crate::Result<(String, String)> {
let parts: Vec<&str> = full_name.trim().split('/').collect();
let [owner, repo] = parts.as_slice() else {
return Err("repository name must be in owner/repo format".into());
};
let (owner, repo) = (owner.trim(), repo.trim());
if owner.is_empty() || repo.is_empty() {
return Err("repository name must be in owner/repo format".into());
}
Ok((owner.to_string(), repo.to_string()))
}
/// Copies at most `max_bytes`; returns the number copied, or an error once the source exceeds it.
fn copy_limited_archive(dst: &mut impl Write, src: impl Read, max_bytes: u64) -> crate::Result<u64> {
let n = std::io::copy(&mut src.take(max_bytes + 1), dst)?;
if n > max_bytes {
return Err(format!("archive exceeds {max_bytes} bytes").into());
}
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn read_limited_rejects_oversized_response() {
let err = read_limited(&b"abcd"[..], 3).unwrap_err();
assert!(err.to_string().contains("response body exceeds"), "{err}");
}
#[test]
fn read_limited_allows_response_at_limit() {
assert_eq!(read_limited(&b"abc"[..], 3).unwrap(), b"abc");
}
#[test]
fn endpoint_url_escapes_repo_path_segments() {
let client = Client::new("https://ghe.example.com/api/v3", "");
let got = client.endpoint_url(&["repos", "owner", "repo", "commits", "feature/a b"]).unwrap();
assert_eq!(got, "https://ghe.example.com/api/v3/repos/owner/repo/commits/feature%2Fa%20b");
assert!(client.endpoint_url(&["repos", ""]).is_err());
}
#[test]
fn split_repo_full_name_rejects_extra_segments() {
assert!(split_repo_full_name("owner/repo/../../user").is_err());
assert!(split_repo_full_name("owner/").is_err());
assert_eq!(split_repo_full_name(" a / b ").unwrap(), ("a".into(), "b".into()));
}
#[test]
fn copy_limited_archive_rejects_oversized_stream() {
let mut dst = Vec::new();
let err = copy_limited_archive(&mut dst, &b"abcd"[..], 3).unwrap_err();
assert!(err.to_string().contains("archive exceeds"), "{err}");
assert_eq!(dst.len(), 4);
}
#[test]
fn copy_limited_archive_allows_stream_at_limit() {
let mut dst = Vec::new();
assert_eq!(copy_limited_archive(&mut dst, &b"abc"[..], 3).unwrap(), 3);
assert_eq!(dst, b"abc");
}
type Seen = Arc<Mutex<Vec<(String, Option<String>, Option<String>)>>>;
/// A local HTTP server answering with `handler(url) -> (status, body)`; records
/// (url, authorization, user-agent) for every request.
fn serve(handler: impl Fn(&str) -> (u16, String) + Send + 'static) -> (String, Seen) {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let base = format!("http://{}", server.server_addr().to_ip().unwrap());
let seen: Seen = Arc::default();
let log = seen.clone();
std::thread::spawn(move || {
for req in server.incoming_requests() {
let header = |name: &'static str| {
req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string())
};
log.lock().unwrap().push((req.url().to_string(), header("Authorization"), header("User-Agent")));
let (status, body) = handler(req.url());
let _ = req.respond(tiny_http::Response::from_string(body).with_status_code(status));
}
});
(base, seen)
}
#[test]
fn validate_token_returns_login_and_reports_status() {
let (base, seen) = serve(|_| (200, r#"{"login":"octocat"}"#.into()));
assert_eq!(Client::new(&base, "tok").validate_token().unwrap(), "octocat");
let (url, auth, ua) = seen.lock().unwrap()[0].clone();
assert_eq!(url, "/user");
assert_eq!(auth.as_deref(), Some("Bearer tok"));
assert_eq!(ua.as_deref(), Some(USER_AGENT));
let (base, _) = serve(|_| (401, "{}".into()));
let err = Client::new(&base, "bad").validate_token().unwrap_err();
assert_eq!(err.to_string(), "token validation failed (HTTP 401)");
}
fn repo_json(n: usize, pushed: &str) -> serde_json::Value {
serde_json::json!({
"full_name": format!("me/r{n}"), "name": format!("r{n}"), "description": null,
"html_url": format!("https://example/me/r{n}"), "default_branch": if n == 0 { "" } else { "main" },
"private": n % 2 == 0, "fork": false, "archived": false, "language": null,
"stargazers_count": n, "pushed_at": pushed, "updated_at": "2024-01-01T00:00:00Z",
"owner": {"login": "me"}
})
}
#[test]
fn fetch_repos_paginates_sorts_and_enriches() {
let (base, seen) = serve(|url| {
if url.starts_with("/user/repos") && url.contains("page=1&") {
let repos: Vec<_> = (0..100)
.map(|n| repo_json(n, &format!("2024-02-{:02}T00:00:00Z", 1 + n % 28)))
.collect();
(200, serde_json::to_string(&repos).unwrap())
} else if url.starts_with("/user/repos") {
(200, serde_json::to_string(&[repo_json(100, "2025-06-01T12:00:00Z")]).unwrap())
} else if url == "/repos/me/r100/commits/main" {
(200, r#"{"commit":{"committer":{"name":"","date":"0001-01-01T00:00:00Z"},"author":{"name":"Ann","date":"2025-06-01T11:00:00Z"}}}"#.into())
} else if url.starts_with("/repos/me/r0/commits/HEAD") {
(200, r#"{"commit":{"committer":{"name":"Bob","date":"2024-01-05T10:00:00+00:00"}}}"#.into())
} else {
(404, "nope".into())
}
});
let calls = Mutex::new(Vec::new());
let repos = Client::new(&base, "t")
.fetch_repos_with_progress(|done, total| calls.lock().unwrap().push((done, total)))
.unwrap();
assert_eq!(repos.len(), 101);
assert_eq!(repos[0].full_name, "me/r100");
assert_eq!(repos[0].pushed_at, "2025-06-01T12:00:00Z");
assert_eq!(repos[0].latest_committer, "Ann");
assert_eq!(repos[0].latest_commit_at, "2025-06-01T11:00:00Z");
assert!(repos.windows(2).all(|w| w[0].pushed_at >= w[1].pushed_at));
let r0 = repos.iter().find(|r| r.name == "r0").unwrap();
assert_eq!((r0.latest_committer.as_str(), r0.latest_commit_at.as_str()), ("Bob", "2024-01-05T10:00:00Z"));
assert_eq!(r0.owner, "me");
assert!(r0.private && r0.description.is_empty() && r0.language.is_empty());
assert!(repos.iter().filter(|r| r.latest_committer.is_empty()).count() == 99);
let mut calls = calls.into_inner().unwrap();
assert_eq!(calls[0], (0, 101));
calls.sort();
assert_eq!(calls.last(), Some(&(101, 101)));
assert_eq!(calls.len(), 102);
assert_eq!(seen.lock().unwrap().iter().filter(|s| s.0.starts_with("/user/repos")).count(), 2);
}
#[test]
fn fetch_repos_reports_auth_and_api_errors() {
let (base, _) = serve(|_| (403, "{}".into()));
let err = Client::new(&base, "t").fetch_repos().unwrap_err().to_string();
assert_eq!(err, "authentication failed (HTTP 403) — check your access token");
let (base, _) = serve(|_| (500, "boom".into()));
let err = Client::new(&base, "t").fetch_repos().unwrap_err().to_string();
assert_eq!(err, "GitHub API returned HTTP 500: boom");
}
#[test]
fn download_repo_archive_writes_file() {
let (base, seen) = serve(|url| match url {
"/repos/me/r/zipball/v1%2F2" => (200, "ZIPDATA".into()),
_ => (404, " missing \n".into()),
});
let dir = std::env::temp_dir().join(format!("nexus-gh-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("a.zip");
let client = Client::new(&base, "t");
client.download_repo_archive("me/r", "v1/2", &dest).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"ZIPDATA");
let err = client.download_repo_archive("me/r", "", &dest).unwrap_err().to_string();
assert_eq!(err, "GitHub API returned HTTP 404: missing");
assert_eq!(seen.lock().unwrap()[1].0, "/repos/me/r/zipball");
assert!(client.download_repo_archive("bad", "", &dest).is_err());
std::fs::remove_dir_all(dir).unwrap();
}
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -6,6 +6,9 @@
//! - `write`/`resize`/`close` are callable from any thread; `close` is idempotent and must not
//! fire `on_close` re-entrantly while holding locks.
pub mod github;
pub mod irc;
use std::sync::Arc;
pub type DataFn = Arc<dyn Fn(&[u8]) + Send + Sync>;
+469
View File
@@ -0,0 +1,469 @@
//! mIRC formatting (^B ^] ^^ ^_ ^V ^Q ^O ^C fg,bg ^D hex) to styled spans, the nick colour hash
//! and the IRC event line layout. Ported from the Wails frontend's `irc.js`.
//!
//! Colours are xterm-256 palette indices: IRC colour codes are mapped through `IRC_COLOURS`.
use crate::clients::irc::Event;
/// IRC colours 00-98 mapped to the xterm 256-colour palette (modern IRC formatting spec).
/// Colour 99, and anything out of range, means the terminal default.
pub const IRC_COLOURS: [u8; 99] = [
15, 0, 4, 2, 9, 1, 5, 130, 11, 10, 6, 14, 12, 13, 8, 7, //
52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89, //
88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125, //
124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161, //
196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198, //
203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205, //
217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212, //
16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
];
/// Palette indices used for nicks.
pub const NICK_COLOURS: [u8; 12] = [33, 39, 45, 69, 75, 81, 111, 141, 177, 207, 214, 220];
const GREY: u8 = 244;
const RED: u8 = 203;
const GREEN: u8 = 83;
const YELLOW: u8 = 221;
const CYAN: u8 = 81;
const MAGENTA: u8 = 177;
/// Text style. `fg`/`bg` are xterm-256 indices; `fg_rgb`/`bg_rgb` come from ^D hex colours.
/// At most one of `fg`/`fg_rgb` (and of `bg`/`bg_rgb`) is set. `None` is the default colour.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Style {
pub fg: Option<u8>,
pub bg: Option<u8>,
pub fg_rgb: Option<[u8; 3]>,
pub bg_rgb: Option<[u8; 3]>,
pub bold: bool,
pub dim: bool,
pub italic: bool,
pub underline: bool,
pub reverse: bool,
pub strike: bool,
pub monospace: bool,
}
impl Style {
fn fg(colour: u8) -> Style {
Style { fg: Some(colour), ..Style::default() }
}
fn set_fg(&mut self, colour: u8) {
self.fg = Some(colour);
self.fg_rgb = None;
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Span {
pub text: String,
pub style: Style,
}
/// Spans under construction; `style` applies to the next text pushed.
#[derive(Default)]
struct Line {
spans: Vec<Span>,
style: Style,
}
impl Line {
fn push(&mut self, text: &str) {
if text.is_empty() {
return;
}
match self.spans.last_mut() {
Some(last) if last.style == self.style => last.text.push_str(text),
_ => self.spans.push(Span { text: text.to_string(), style: self.style }),
}
}
/// `text` in `style`, then back to the default style.
fn styled(&mut self, style: Style, text: &str) {
self.style = style;
self.push(text);
self.style = Style::default();
}
/// A bold, nick-coloured name layered on the current style, then reset.
fn nick(&mut self, nick: &str) {
let mut style = self.style;
style.bold = true;
style.set_fg(nick_colour(nick));
self.styled(style, nick);
}
/// Applies mIRC formatting codes in `value` on top of the current style.
fn mirc(&mut self, value: &str) {
let input: Vec<char> = strip_terminal_escapes(value).chars().collect();
// Toggle states start off regardless of the inherited style, as in irc.js.
let (mut bold, mut italic, mut strike, mut underline, mut reverse, mut mono) = (false, false, false, false, false, false);
let mut i = 0;
while i < input.len() {
let c = input[i];
i += 1;
match c {
'\x02' => {
bold = !bold;
self.style.bold = bold;
if !bold {
self.style.dim = false; // SGR 22 clears both
}
}
'\x1d' => {
italic = !italic;
self.style.italic = italic;
}
'\x1e' => {
strike = !strike;
self.style.strike = strike;
}
'\x1f' => {
underline = !underline;
self.style.underline = underline;
}
'\x16' => {
reverse = !reverse;
self.style.reverse = reverse;
}
'\x11' => {
mono = !mono;
self.style.monospace = mono;
}
'\x0f' => {
self.style = Style::default();
(bold, italic, strike, underline, reverse, mono) = (false, false, false, false, false, false);
}
'\x03' | '\x04' => {
let hex = c == '\x04';
let read = |at: usize| if hex { read_hex(&input, at) } else { read_decimal(&input, at) };
let Some((fg, len)) = read(i) else {
self.reset_colours();
continue;
};
i += len;
self.apply_colour(fg, false);
if input.get(i) != Some(&',') {
continue;
}
match read(i + 1) {
Some((bg, len)) => {
i += len + 1;
self.apply_colour(bg, true);
}
None => {
self.push(",");
i += 1;
}
}
}
c if (c as u32) < 0x20 || c as u32 == 0x7f => {}
c => self.push(c.encode_utf8(&mut [0; 4])),
}
}
}
fn reset_colours(&mut self) {
(self.style.fg, self.style.bg, self.style.fg_rgb, self.style.bg_rgb) = (None, None, None, None);
}
fn apply_colour(&mut self, colour: Colour, background: bool) {
let s = &mut self.style;
let (index, rgb) = if background { (&mut s.bg, &mut s.bg_rgb) } else { (&mut s.fg, &mut s.fg_rgb) };
match colour {
Colour::Indexed(code) => {
*index = IRC_COLOURS.get(code as usize).copied();
*rgb = None;
}
Colour::Rgb(value) => {
*rgb = Some(value);
*index = None;
}
}
}
}
enum Colour {
Indexed(u8),
Rgb([u8; 3]),
}
fn read_decimal(input: &[char], at: usize) -> Option<(Colour, usize)> {
let digits: String = input.iter().skip(at).take(2).take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
Some((Colour::Indexed(digits.parse().ok()?), digits.len()))
}
fn read_hex(input: &[char], at: usize) -> Option<(Colour, usize)> {
let hex: String = input.get(at..at + 6)?.iter().collect();
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
Some((Colour::Rgb([byte(0)?, byte(2)?, byte(4)?]), 6))
}
/// Removes untrusted terminal sequences (OSC, CSI, other ESC pairs) and C1 controls, applying the
/// same four passes as irc.js.
fn strip_terminal_escapes(value: &str) -> String {
// OSC: ESC ] ... (BEL | ESC \), greedy like the JS regex.
let mut s = String::new();
let mut rest = value;
while let Some(pos) = rest.find("\x1b]") {
let body = &rest[pos + 2..];
let run_end = body.find('\x07');
let end = match run_end {
Some(bel) => Some(bel + 1),
None => body.rfind("\x1b\\").map(|st| st + 2),
};
match end {
Some(end) => {
s.push_str(&rest[..pos]);
rest = &body[end..];
}
None => {
s.push_str(&rest[..pos + 2]);
rest = body;
}
}
}
s.push_str(rest);
// CSI: ESC [ params intermediates final.
let chars: Vec<char> = s.chars().collect();
let mut csi = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '\x1b' && chars.get(i + 1) == Some(&'[') {
let mut j = i + 2;
while chars.get(j).is_some_and(|c| ('0'..='?').contains(c)) {
j += 1;
}
while chars.get(j).is_some_and(|c| (' '..='/').contains(c)) {
j += 1;
}
if chars.get(j).is_some_and(|c| ('@'..='~').contains(c)) {
i = j + 1;
continue;
}
}
csi.push(chars[i]);
i += 1;
}
// ESC plus any character but a line terminator.
let chars: Vec<char> = csi.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '\x1b' && chars.get(i + 1).is_some_and(|c| !matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')) {
i += 2;
continue;
}
out.push(chars[i]);
i += 1;
}
out.retain(|c| !('\u{80}'..='\u{9f}').contains(&c));
out
}
/// mIRC-formatted text as styled spans, starting from the default style.
pub fn format_text(value: &str) -> Vec<Span> {
let mut line = Line::default();
line.mirc(value);
line.spans
}
/// The text with all formatting removed.
pub fn plain_text(value: &str) -> String {
format_text(value).into_iter().map(|s| s.text).collect()
}
/// The palette index for a nick (Java-style string hash over code points).
pub fn nick_colour(nick: &str) -> u8 {
let hash = nick.chars().fold(0u32, |h, c| h.wrapping_mul(31).wrapping_add(c as u32));
NICK_COLOURS[hash as usize % NICK_COLOURS.len()]
}
/// One chat line for an event: `[time] body`, laid out per event type as the Wails viewer did.
pub fn format_event(event: &Event, time: &str) -> Vec<Span> {
let from = plain_text(&event.from);
let subject = plain_text(&event.command);
let mut l = Line::default();
l.styled(Style { dim: true, ..Style::fg(GREY) }, &format!("[{time}]"));
l.push(" ");
let nick_or = |l: &mut Line, fallback: &str| {
if from.is_empty() { l.push(fallback) } else { l.nick(&from) }
};
let suffix = |l: &mut Line| {
if !event.text.is_empty() {
l.push(" (");
l.mirc(&event.text);
l.push(")");
}
};
let magenta_italic = Style { italic: true, ..Style::fg(MAGENTA) };
match event.kind.as_str() {
"message" => {
l.styled(Style::fg(GREY), "<");
nick_or(&mut l, "");
l.styled(Style::fg(GREY), ">");
l.push(" ");
l.mirc(&event.text);
}
"action" => {
l.style = magenta_italic;
l.push("* ");
nick_or(&mut l, "");
l.style = magenta_italic;
l.push(" ");
l.mirc(&event.text);
}
"notice" => {
l.style = Style::fg(YELLOW);
l.push("-");
nick_or(&mut l, "notice");
l.styled(Style::fg(YELLOW), "-");
l.push(" ");
l.mirc(&event.text);
}
"join" => {
l.styled(Style::fg(GREEN), "-->");
l.push(" ");
nick_or(&mut l, "user");
l.push(" joined");
}
"part" | "quit" => {
l.styled(Style::fg(RED), "<--");
l.push(" ");
nick_or(&mut l, "user");
l.push(if event.kind == "part" { " left" } else { " quit" });
suffix(&mut l);
}
"kick" => {
l.styled(Style::fg(RED), "<--");
l.push(" ");
l.styled(Style { bold: true, ..Style::default() }, if subject.is_empty() { "user" } else { &subject });
l.push(" was kicked by ");
nick_or(&mut l, "server");
suffix(&mut l);
}
"nick" => {
nick_or(&mut l, "user");
l.push(" is now known as ");
l.styled(Style { bold: true, ..Style::default() }, &plain_text(&event.text));
}
"topic" | "error" => {
let (colour, label) = if event.kind == "topic" { (CYAN, "topic:") } else { (RED, "error:") };
l.styled(Style { bold: true, ..Style::fg(colour) }, label);
l.push(" ");
l.mirc(&event.text);
}
_ => {
l.style = Style::fg(CYAN);
l.mirc(&event.text);
}
}
l.spans
}
#[cfg(test)]
mod tests {
use super::*;
fn span(text: &str, style: Style) -> Span {
Span { text: text.into(), style }
}
#[test]
fn toggles() {
let b = Style { bold: true, ..Style::default() };
let i = Style { italic: true, ..Style::default() };
let u = Style { underline: true, ..Style::default() };
let d = Style::default();
assert_eq!(
format_text("plain \x02bold\x02 \x1ditalic\x1d \x1funderlined\x1f"),
vec![span("plain ", d), span("bold", b), span(" ", d), span("italic", i), span(" ", d), span("underlined", u)]
);
let all = format_text("\x16\x1e\x11x\x0fy");
assert_eq!(all[0].style, Style { reverse: true, strike: true, monospace: true, ..Style::default() });
assert_eq!(all[1], span("y", d));
}
#[test]
fn indexed_colours() {
assert_eq!(format_text("\x0304red\x03 default"), vec![span("red", Style::fg(9)), span(" default", Style::default())]);
assert_eq!(
format_text("\x0352extended \x0303,01matrix"),
vec![span("extended ", Style::fg(196)), span("matrix", Style { bg: Some(0), ..Style::fg(2) })]
);
assert_eq!(format_text("\x0399default"), vec![span("default", Style::default())]);
}
#[test]
fn hex_colours() {
assert_eq!(
format_text("\x04ff8800orange\x04 default"),
vec![
span("orange", Style { fg_rgb: Some([255, 136, 0]), ..Style::default() }),
span(" default", Style::default())
]
);
let s = format_text("\x0400ff00,0000FFx");
assert_eq!(s[0].style, Style { fg_rgb: Some([0, 255, 0]), bg_rgb: Some([0, 0, 255]), ..Style::default() });
}
#[test]
fn colour_without_background_keeps_comma() {
assert_eq!(format_text("\x0304,comma"), vec![span(",comma", Style::fg(9))]);
}
#[test]
fn plain_text_strips_formatting() {
assert_eq!(plain_text("\x02bold\x02 \x0304red\x03"), "bold red");
}
#[test]
fn untrusted_terminal_sequences_are_removed() {
assert_eq!(plain_text("before\x1b[31munsafe\x1b]0;title\x07after"), "beforeunsafeafter");
assert_eq!(plain_text("a\x1b]8;;x\x1b\\b\u{9b}c\x1bZd\x01"), "abcd");
}
#[test]
fn nick_colour_matches_js_hash() {
// Hashes computed with the irc.js function under node.
assert_eq!(nick_colour(""), 33);
assert_eq!(nick_colour("bob"), NICK_COLOURS[97717 % 12]);
assert_eq!(nick_colour("alice"), NICK_COLOURS[92903040 % 12]);
}
#[test]
fn event_lines() {
let message = format_event(
&Event { kind: "message".into(), from: "alice".into(), text: "hello \x02world\x02".into(), ..Default::default() },
"12:34:56",
);
assert_eq!(message[0], span("[12:34:56]", Style { dim: true, ..Style::fg(GREY) }));
assert!(message.iter().any(|s| s.text == "world" && s.style.bold));
let nick = message.iter().find(|s| s.text == "alice").unwrap();
assert_eq!(nick.style, Style { bold: true, ..Style::fg(nick_colour("alice")) });
let text: String = message.iter().map(|s| s.text.as_str()).collect();
assert_eq!(text, "[12:34:56] <alice> hello world");
let action = format_event(&Event { kind: "action".into(), from: "bob".into(), text: "waves".into(), ..Default::default() }, "01:02:03");
assert!(action.iter().any(|s| s.text.ends_with("waves") && s.style.italic && s.style.fg == Some(MAGENTA)));
let kick = format_event(
&Event { kind: "kick".into(), from: "op".into(), command: "eve".into(), text: "spam".into(), ..Default::default() },
"t",
);
assert_eq!(kick.iter().map(|s| s.text.as_str()).collect::<String>(), "[t] <-- eve was kicked by op (spam)");
let system = format_event(&Event { kind: "system".into(), text: "hi".into(), ..Default::default() }, "t");
assert_eq!(system.last().unwrap(), &span("hi", Style::fg(CYAN)));
}
}
+1
View File
@@ -4,6 +4,7 @@
pub mod clients;
pub mod config;
pub mod exportimport;
pub mod ircformat;
pub mod models;
pub mod secrets;
pub mod storage;