#![allow(clippy::result_large_err)]
use crate::logger;
pub struct GithubApi {
token: String,
repo: String,
}
pub type GithubApiError = Box<dyn std::error::Error>;
fn is_status_retriable(code: u16) -> bool {
code == 429 || code >= 500
}
fn classify(err: ureq::Error) -> (bool, GithubApiError) {
let retriable = match &err {
ureq::Error::Status(code, _) => is_status_retriable(*code),
ureq::Error::Transport(_) => true,
};
(retriable, Box::new(err))
}
#[allow(clippy::result_large_err)]
fn with_retry<F, T>(f: F) -> Result<T, GithubApiError>
where
F: Fn() -> Result<T, ureq::Error>,
{
let delays_secs = [1u64, 2]; let max_attempts: usize = 3;
let mut last_err: GithubApiError =
Box::<dyn std::error::Error>::from("github_api: retry exhausted with no recorded error");
for attempt in 0..max_attempts {
if attempt > 0 {
let delay = delays_secs[attempt - 1];
std::thread::sleep(std::time::Duration::from_secs(delay));
}
match f() {
Ok(val) => return Ok(val),
Err(e) => {
let (retriable, boxed) = classify(e);
if retriable {
last_err = boxed;
} else {
return Err(boxed);
}
}
}
}
Err(last_err)
}
impl GithubApi {
pub fn new(token: &str, repo: &str) -> Self {
Self {
token: token.to_string(),
repo: repo.to_string(),
}
}
pub fn put_file(&self, path: &str, content: &str) -> Result<(), GithubApiError> {
let encoded = base64_encode(content.as_bytes());
let current_sha = match with_retry(|| get_file_sha_inner(&self.token, &self.repo, path)) {
Ok(sha) => sha,
Err(e) => {
logger::error(&format!(
"github_api: get_file_sha failed for {}: {}",
path, e
));
return Err(e);
}
};
match with_retry(|| put_file_inner(&self.token, &self.repo, path, &encoded, ¤t_sha)) {
Ok(()) => Ok(()),
Err(e) => {
logger::error(&format!("github_api: put_file failed for {}: {}", path, e));
Err(e)
}
}
}
#[allow(dead_code)] pub fn get_file_sha(&self, path: &str) -> Result<Option<String>, GithubApiError> {
with_retry(|| get_file_sha_inner(&self.token, &self.repo, path))
}
pub fn get_file_content(&self, path: &str) -> Result<Option<String>, GithubApiError> {
with_retry(|| get_file_content_inner(&self.token, &self.repo, path))
}
pub fn delete_file(&self, path: &str) -> Result<(), GithubApiError> {
let sha = match with_retry(|| get_file_sha_inner(&self.token, &self.repo, path)) {
Ok(Some(sha)) => sha,
Ok(None) => return Ok(()), Err(e) => {
logger::error(&format!(
"github_api: get_file_sha failed for {}: {}",
path, e
));
return Err(e);
}
};
match with_retry(|| delete_file_inner(&self.token, &self.repo, path, &sha)) {
Ok(()) => Ok(()),
Err(e) => {
logger::error(&format!(
"github_api: delete_file failed for {}: {}",
path, e
));
Err(e)
}
}
}
pub fn list_directory_all(
&self,
path: &str,
) -> Result<(Vec<String>, Vec<String>), GithubApiError> {
with_retry(|| list_directory_all_inner(&self.token, &self.repo, path))
}
pub fn get_user(&self) -> Result<String, GithubApiError> {
with_retry(|| get_user_inner(&self.token))
}
}
#[allow(clippy::result_large_err)]
fn get_file_sha_inner(token: &str, repo: &str, path: &str) -> Result<Option<String>, ureq::Error> {
let url = format!("https://api.github.com/repos/{}/contents/{}", repo, path);
let response = ureq::get(&url)
.set("Authorization", &format!("Bearer {}", token))
.set("User-Agent", "vibestats")
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.call();
match response {
Ok(r) => {
let body = r.into_string().map_err(ureq::Error::from)?;
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
ureq::Error::from(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("github_api: malformed JSON from Contents API: {}", e),
))
})?;
Ok(json["sha"].as_str().map(|s| s.to_string()))
}
Err(ureq::Error::Status(404, _)) => {
Ok(None)
}
Err(e) => Err(e),
}
}
#[allow(clippy::result_large_err)]
fn put_file_inner(
token: &str,
repo: &str,
path: &str,
encoded_content: &str,
current_sha: &Option<String>,
) -> Result<(), ureq::Error> {
let url = format!("https://api.github.com/repos/{}/contents/{}", repo, path);
let body = if let Some(sha) = current_sha {
serde_json::json!({
"message": "vibestats sync",
"content": encoded_content,
"sha": sha
})
.to_string()
} else {
serde_json::json!({
"message": "vibestats sync",
"content": encoded_content
})
.to_string()
};
let response = ureq::put(&url)
.set("Authorization", &format!("Bearer {}", token))
.set("User-Agent", "vibestats")
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.set("Content-Type", "application/json")
.send_string(&body);
match response {
Ok(_) => Ok(()), Err(e) => Err(e),
}
}
#[allow(clippy::result_large_err)]
fn get_file_content_inner(
token: &str,
repo: &str,
path: &str,
) -> Result<Option<String>, ureq::Error> {
let url = format!("https://api.github.com/repos/{}/contents/{}", repo, path);
let response = ureq::get(&url)
.set("Authorization", &format!("Bearer {}", token))
.set("User-Agent", "vibestats")
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.call();
match response {
Ok(r) => {
let body = r.into_string().map_err(ureq::Error::from)?;
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
ureq::Error::from(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("github_api: malformed JSON from Contents API: {}", e),
))
})?;
let encoded = match json["content"].as_str() {
Some(s) => s,
None => {
return Err(ureq::Error::from(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"github_api: missing content field in Contents API response",
)));
}
};
let stripped = encoded.replace('\n', "");
match base64_decode(&stripped) {
Ok(content) => Ok(Some(content)),
Err(e) => Err(ureq::Error::from(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("github_api: base64 decode failed: {}", e),
))),
}
}
Err(ureq::Error::Status(404, _)) => {
Ok(None)
}
Err(e) => Err(e),
}
}
#[allow(clippy::result_large_err)]
fn delete_file_inner(token: &str, repo: &str, path: &str, sha: &str) -> Result<(), ureq::Error> {
let url = format!("https://api.github.com/repos/{}/contents/{}", repo, path);
let body = serde_json::json!({
"message": "vibestats: remove machine data",
"sha": sha
})
.to_string();
let response = ureq::delete(&url)
.set("Authorization", &format!("Bearer {}", token))
.set("User-Agent", "vibestats")
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.set("Content-Type", "application/json")
.send_string(&body);
match response {
Ok(_) => Ok(()),
Err(ureq::Error::Status(404, _)) => Ok(()), Err(e) => Err(e),
}
}
#[allow(clippy::result_large_err)]
fn list_directory_all_inner(
token: &str,
repo: &str,
path: &str,
) -> Result<(Vec<String>, Vec<String>), ureq::Error> {
let url = format!("https://api.github.com/repos/{}/contents/{}", repo, path);
let response = ureq::get(&url)
.set("Authorization", &format!("Bearer {}", token))
.set("User-Agent", "vibestats")
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.call();
match response {
Ok(r) => {
let body = r.into_string().map_err(ureq::Error::from)?;
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
ureq::Error::from(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"github_api: malformed JSON from Contents API directory: {}",
e
),
))
})?;
let entries = match json.as_array() {
Some(arr) => arr,
None => {
return Err(ureq::Error::from(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"github_api: directory listing response is not a JSON array",
)));
}
};
let mut files = Vec::new();
let mut dirs = Vec::new();
for entry in entries {
let entry_type = entry["type"].as_str().unwrap_or("");
let entry_path = entry["path"].as_str().unwrap_or("").to_string();
if !entry_path.is_empty() {
match entry_type {
"file" => files.push(entry_path),
"dir" => dirs.push(entry_path),
_ => {}
}
}
}
Ok((files, dirs))
}
Err(ureq::Error::Status(404, _)) => Ok((vec![], vec![])),
Err(e) => Err(e),
}
}
#[allow(clippy::result_large_err)]
fn get_user_inner(token: &str) -> Result<String, ureq::Error> {
let url = "https://api.github.com/user";
let response = ureq::get(url)
.set("Authorization", &format!("Bearer {}", token))
.set("User-Agent", "vibestats")
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.call();
match response {
Ok(r) => {
let body = r.into_string().map_err(ureq::Error::from)?;
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
ureq::Error::from(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("github_api: malformed JSON from /user: {}", e),
))
})?;
match json["login"].as_str() {
Some(login) => Ok(login.to_string()),
None => Err(ureq::Error::from(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"github_api: missing login field in /user response",
))),
}
}
Err(e) => Err(e),
}
}
fn base64_encode(input: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
let mut i = 0;
while i + 2 < input.len() {
let b0 = input[i] as usize;
let b1 = input[i + 1] as usize;
let b2 = input[i + 2] as usize;
out.push(ALPHABET[b0 >> 2] as char);
out.push(ALPHABET[((b0 & 0x3) << 4) | (b1 >> 4)] as char);
out.push(ALPHABET[((b1 & 0xf) << 2) | (b2 >> 6)] as char);
out.push(ALPHABET[b2 & 0x3f] as char);
i += 3;
}
match input.len() - i {
1 => {
let b0 = input[i] as usize;
out.push(ALPHABET[b0 >> 2] as char);
out.push(ALPHABET[(b0 & 0x3) << 4] as char);
out.push('=');
out.push('=');
}
2 => {
let b0 = input[i] as usize;
let b1 = input[i + 1] as usize;
out.push(ALPHABET[b0 >> 2] as char);
out.push(ALPHABET[((b0 & 0x3) << 4) | (b1 >> 4)] as char);
out.push(ALPHABET[(b1 & 0xf) << 2] as char);
out.push('=');
}
_ => {} }
out
}
fn base64_decode(input: &str) -> Result<String, &'static str> {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut rev = [255u8; 256];
for (i, &c) in TABLE.iter().enumerate() {
rev[c as usize] = i as u8;
}
let input: Vec<u8> = input.bytes().filter(|&b| b != b'=').collect();
let mut out = Vec::new();
for chunk in input.chunks(4) {
let vals: Vec<u8> = chunk.iter().map(|&b| rev[b as usize]).collect();
if vals.contains(&255) {
return Err("invalid base64 character");
}
match vals.len() {
4 => {
out.push((vals[0] << 2) | (vals[1] >> 4));
out.push((vals[1] << 4) | (vals[2] >> 2));
out.push((vals[2] << 6) | vals[3]);
}
3 => {
out.push((vals[0] << 2) | (vals[1] >> 4));
out.push((vals[1] << 4) | (vals[2] >> 2));
}
2 => {
out.push((vals[0] << 2) | (vals[1] >> 4));
}
_ => {}
}
}
String::from_utf8(out).map_err(|_| "base64 decoded bytes are not valid UTF-8")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_base64_empty() {
assert_eq!(base64_encode(b""), "");
}
#[test]
fn test_base64_one_byte() {
assert_eq!(base64_encode(b"M"), "TQ==");
}
#[test]
fn test_base64_two_bytes() {
assert_eq!(base64_encode(b"Ma"), "TWE=");
}
#[test]
fn test_base64_three_bytes() {
assert_eq!(base64_encode(b"Man"), "TWFu");
}
#[test]
fn test_base64_four_bytes() {
assert_eq!(base64_encode(b"Many"), "TWFueQ==");
}
#[test]
fn test_base64_hello() {
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
}
#[test]
fn test_base64_all_zeros() {
assert_eq!(base64_encode(&[0u8, 0, 0]), "AAAA");
}
#[test]
fn test_base64_all_ones() {
assert_eq!(base64_encode(&[0xFFu8, 0xFF, 0xFF]), "////");
}
#[test]
fn test_base64_longer_string() {
assert_eq!(
base64_encode(b"Many hands make light work."),
"TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcmsu"
);
}
#[test]
fn test_status_retriable_429() {
assert!(
is_status_retriable(429),
"429 (rate limit) must be retriable"
);
}
#[test]
fn test_status_retriable_500() {
assert!(
is_status_retriable(500),
"500 (server error) must be retriable"
);
}
#[test]
fn test_status_retriable_503() {
assert!(
is_status_retriable(503),
"503 (service unavailable) must be retriable"
);
}
#[test]
fn test_status_retriable_599() {
assert!(is_status_retriable(599), "all 5xx must be retriable");
}
#[test]
fn test_status_not_retriable_401() {
assert!(
!is_status_retriable(401),
"401 (unauthorized) must NOT be retriable"
);
}
#[test]
fn test_status_not_retriable_404() {
assert!(
!is_status_retriable(404),
"404 (not found) must NOT be retriable"
);
}
#[test]
fn test_status_not_retriable_422() {
assert!(
!is_status_retriable(422),
"422 (unprocessable entity) must NOT be retriable"
);
}
#[test]
fn test_status_not_retriable_200() {
assert!(!is_status_retriable(200), "200 (OK) must NOT be retriable");
}
#[test]
fn test_status_not_retriable_400() {
assert!(
!is_status_retriable(400),
"400 (bad request) must NOT be retriable"
);
}
#[test]
fn test_retry_succeeds_on_first_attempt() {
let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let cc = call_count.clone();
let result: Result<i32, GithubApiError> = with_retry(|| {
cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(42)
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), 42);
assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn test_retry_invokes_f_exactly_once_on_success() {
let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let cc = call_count.clone();
let _ = with_retry::<_, ()>(|| {
cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
});
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"f should be invoked exactly once when it succeeds immediately"
);
}
fn make_transport_error() -> ureq::Error {
ureq::Error::from(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"simulated network error for test",
))
}
#[test]
fn test_classify_transport_error_is_retriable() {
let err = make_transport_error();
let (retriable, _) = classify(err);
assert!(
retriable,
"transport errors must be classified as retriable"
);
}
#[test]
fn test_retry_transport_error_exhausts_3_attempts() {
let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let cc = call_count.clone();
let result: Result<(), GithubApiError> = with_retry(|| {
cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(make_transport_error())
});
assert!(result.is_err());
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
3,
"transport error should trigger 3 total attempts (no early exit)"
);
}
#[test]
fn test_retry_succeeds_after_two_transport_errors() {
let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let cc = call_count.clone();
let result: Result<i32, GithubApiError> = with_retry(|| {
let n = cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if n < 2 {
Err(make_transport_error())
} else {
Ok(77)
}
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), 77);
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
3,
"should have been called exactly 3 times (2 failures + 1 success)"
);
}
#[test]
fn test_parse_sha_present_in_json_body() {
let json_body = r#"{"sha": "abc123def456", "content": "aGVsbG8=", "encoding": "base64"}"#;
let json: serde_json::Value = serde_json::from_str(json_body).unwrap();
let sha = json["sha"].as_str().map(|s| s.to_string());
assert_eq!(sha, Some("abc123def456".to_string()));
}
#[test]
fn test_parse_sha_missing_field_returns_none() {
let json_body = r#"{"content": "aGVsbG8=", "encoding": "base64"}"#;
let json: serde_json::Value = serde_json::from_str(json_body).unwrap();
let sha = json["sha"].as_str().map(|s| s.to_string());
assert_eq!(sha, None);
}
#[test]
fn test_put_body_without_sha_excludes_sha_field() {
let sha: Option<String> = None;
let body = build_put_body("aGVsbG8=", &sha);
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(
parsed.get("sha").is_none(),
"body must not include sha field when creating a new file"
);
assert_eq!(parsed["message"], "vibestats sync");
assert_eq!(parsed["content"], "aGVsbG8=");
}
#[test]
fn test_put_body_with_sha_includes_sha_field() {
let sha: Option<String> = Some("abc123".to_string());
let body = build_put_body("aGVsbG8=", &sha);
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
parsed["sha"], "abc123",
"body must include sha field when updating existing file"
);
assert_eq!(parsed["message"], "vibestats sync");
assert_eq!(parsed["content"], "aGVsbG8=");
}
fn build_put_body(encoded_content: &str, current_sha: &Option<String>) -> String {
if let Some(sha) = current_sha {
serde_json::json!({
"message": "vibestats sync",
"content": encoded_content,
"sha": sha
})
.to_string()
} else {
serde_json::json!({
"message": "vibestats sync",
"content": encoded_content
})
.to_string()
}
}
#[test]
fn test_base64_decode_hello() {
assert_eq!(base64_decode("aGVsbG8=").unwrap(), "hello");
}
#[test]
fn test_base64_decode_empty() {
assert_eq!(base64_decode("").unwrap(), "");
}
#[test]
fn test_base64_decode_roundtrip() {
let original = "vibestats test content";
let encoded = base64_encode(original.as_bytes());
let decoded = base64_decode(&encoded).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn test_base64_decode_invalid_char() {
assert!(base64_decode("aG!sbG8=").is_err());
}
#[test]
fn test_base64_decode_strips_padding() {
assert_eq!(base64_decode("TWFu").unwrap(), "Man");
assert_eq!(base64_decode("TWE=").unwrap(), "Ma");
assert_eq!(base64_decode("TQ==").unwrap(), "M");
}
#[test]
fn test_delete_body_includes_sha_and_message() {
let sha = "deadbeef1234";
let body = build_delete_body(sha);
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["sha"], sha, "delete body must include the sha field");
assert_eq!(
parsed["message"], "vibestats: remove machine data",
"delete body must include the correct commit message"
);
}
#[test]
fn test_delete_body_does_not_include_content_field() {
let body = build_delete_body("abc123");
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(
parsed.get("content").is_none(),
"delete body must not include a content field"
);
}
#[test]
fn test_delete_body_sha_roundtrip() {
let sha = "0000000000000000000000000000000000000000";
let body = build_delete_body(sha);
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["sha"].as_str().unwrap(), sha);
}
fn build_delete_body(sha: &str) -> String {
serde_json::json!({
"message": "vibestats: remove machine data",
"sha": sha
})
.to_string()
}
#[test]
fn test_list_directory_filters_files_only() {
let json_body = r#"[
{"type": "file", "path": "machines/year=2026/month=04/day=10/harness=claude/machine_id=abc123/data.json"},
{"type": "dir", "path": "machines/year=2026/month=04/day=11"},
{"type": "file", "path": "machines/year=2026/month=04/day=10/harness=claude/machine_id=abc123/other.json"}
]"#;
let json: serde_json::Value = serde_json::from_str(json_body).unwrap();
let entries = json.as_array().unwrap();
let paths: Vec<String> = entries
.iter()
.filter(|e| e["type"].as_str() == Some("file"))
.filter_map(|e| e["path"].as_str().map(|s| s.to_string()))
.collect();
assert_eq!(paths.len(), 2);
assert!(paths[0].ends_with("data.json"));
assert!(paths[1].ends_with("other.json"));
}
#[test]
fn test_list_directory_empty_array_returns_empty_vec() {
let json_body = "[]";
let json: serde_json::Value = serde_json::from_str(json_body).unwrap();
let entries = json.as_array().unwrap();
let paths: Vec<String> = entries
.iter()
.filter(|e| e["type"].as_str() == Some("file"))
.filter_map(|e| e["path"].as_str().map(|s| s.to_string()))
.collect();
assert!(paths.is_empty());
}
#[test]
fn test_parse_login_present_in_user_json_body() {
let json_body = r#"{"login": "octocat", "id": 1, "type": "User"}"#;
let json: serde_json::Value = serde_json::from_str(json_body).unwrap();
let login = json["login"].as_str().map(|s| s.to_string());
assert_eq!(login, Some("octocat".to_string()));
}
#[test]
fn test_parse_login_missing_field_returns_none() {
let json_body = r#"{"id": 1, "type": "User"}"#;
let json: serde_json::Value = serde_json::from_str(json_body).unwrap();
let login = json["login"].as_str().map(|s| s.to_string());
assert_eq!(login, None);
}
#[test]
fn test_parse_login_with_hyphenated_username() {
let json_body = r#"{"login": "step-hen-leo", "id": 42}"#;
let json: serde_json::Value = serde_json::from_str(json_body).unwrap();
let login = json["login"].as_str().map(|s| s.to_string());
assert_eq!(login, Some("step-hen-leo".to_string()));
}
#[test]
fn test_github_api_new_stores_token_and_repo() {
let api = GithubApi::new("my-token", "owner/repo");
assert_eq!(api.token, "my-token");
assert_eq!(api.repo, "owner/repo");
}
#[test]
fn test_base64_output_uses_standard_alphabet() {
let content = r#"{"key": "value", "num": 42}"#;
let encoded = base64_encode(content.as_bytes());
assert!(!encoded.is_empty());
for c in encoded.chars() {
assert!(
c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=',
"invalid base64 character in output: {c:?}"
);
}
}
}