releasekit 0.1.0

Client-agnostic library for fetching releases from git forges
Documentation
use releasekit::Forge;
use releasekit::client::{HeaderMap, HttpClient, Response};
use releasekit::platform::GitHub;

#[derive(Clone)]
struct MockClient {
    response: String,
}

impl HttpClient for MockClient {
    fn get(&self, _url: &str, _headers: &HeaderMap) -> releasekit::error::Result<Response> {
        Ok(Response {
            status: 200,
            body: self.response.clone(),
        })
    }
}

fn sample_releases_json() -> &'static str {
    r#"[
        {
            "tag_name": "v1.0.0",
            "name": "Version 1.0",
            "prerelease": false,
            "published_at": "2025-01-15T10:30:00Z",
            "body": "First stable release",
            "assets": [
                {
                    "name": "app-x86_64.AppImage",
                    "size": 5242880,
                    "browser_download_url": "https://example.com/app-x86_64.AppImage"
                },
                {
                    "name": "app-x86_64.tar.gz",
                    "size": 3145728,
                    "browser_download_url": "https://example.com/app-x86_64.tar.gz"
                }
            ]
        },
        {
            "tag_name": "v0.9.0",
            "name": null,
            "prerelease": true,
            "published_at": "2024-12-01T08:00:00Z",
            "body": null,
            "assets": []
        }
    ]"#
}

fn sample_single_release_json() -> &'static str {
    r#"{
        "tag_name": "v2.0.0",
        "name": "Version 2.0",
        "prerelease": false,
        "published_at": "2025-06-01T12:00:00Z",
        "body": "Major update",
        "assets": [
            {
                "name": "app.tar.gz",
                "size": 1024,
                "browser_download_url": "https://example.com/app.tar.gz"
            }
        ]
    }"#
}

#[test]
fn parses_multiple_releases() {
    let client = MockClient {
        response: sample_releases_json().to_string(),
    };
    let gh = GitHub::new(client);
    let releases = gh.fetch_releases("test/repo", None).unwrap();

    assert_eq!(releases.len(), 2);

    let first = &releases[0];
    assert_eq!(first.tag(), "v1.0.0");
    assert_eq!(first.name(), Some("Version 1.0"));
    assert!(!first.is_prerelease());
    assert_eq!(first.published_at(), "2025-01-15T10:30:00Z");
    assert_eq!(first.body(), Some("First stable release"));
    assert_eq!(first.assets().len(), 2);

    let asset = &first.assets()[0];
    assert_eq!(asset.name(), "app-x86_64.AppImage");
    assert_eq!(asset.size(), Some(5_242_880));
    assert_eq!(asset.url(), "https://example.com/app-x86_64.AppImage");

    let second = &releases[1];
    assert_eq!(second.tag(), "v0.9.0");
    assert_eq!(second.name(), None);
    assert!(second.is_prerelease());
    assert_eq!(second.body(), None);
    assert!(second.assets().is_empty());
}

#[test]
fn parses_single_release() {
    let client = MockClient {
        response: sample_single_release_json().to_string(),
    };
    let gh = GitHub::new(client);
    let releases = gh.fetch_releases("test/repo", Some("v2.0.0")).unwrap();

    assert_eq!(releases.len(), 1);
    assert_eq!(releases[0].tag(), "v2.0.0");
    assert_eq!(releases[0].assets().len(), 1);
}

#[test]
fn with_token_sends_auth_header() {
    use std::rc::Rc;

    #[derive(Clone)]
    struct InspectClient {
        response: String,
        got_auth: Rc<std::cell::RefCell<Option<String>>>,
    }

    impl HttpClient for InspectClient {
        fn get(&self, _url: &str, headers: &HeaderMap) -> releasekit::error::Result<Response> {
            for (k, v) in headers.iter() {
                if k == "Authorization" {
                    *self.got_auth.borrow_mut() = Some(v.to_string());
                }
            }
            Ok(Response {
                status: 200,
                body: self.response.clone(),
            })
        }
    }

    let got_auth = Rc::new(std::cell::RefCell::new(None));
    let client = InspectClient {
        response: sample_releases_json().to_string(),
        got_auth: got_auth.clone(),
    };
    let gh = GitHub::new(client).with_token("ghp_test123");
    let _ = gh.fetch_releases("test/repo", None).unwrap();

    assert_eq!(got_auth.borrow().as_deref(), Some("Bearer ghp_test123"));
}

#[test]
fn no_auth_header_without_token() {
    use std::rc::Rc;

    #[derive(Clone)]
    struct InspectClient {
        response: String,
        has_auth: Rc<std::cell::RefCell<bool>>,
    }

    impl HttpClient for InspectClient {
        fn get(&self, _url: &str, headers: &HeaderMap) -> releasekit::error::Result<Response> {
            *self.has_auth.borrow_mut() = headers.iter().any(|(k, _)| k == "Authorization");
            Ok(Response {
                status: 200,
                body: self.response.clone(),
            })
        }
    }

    let has_auth = Rc::new(std::cell::RefCell::new(false));
    let client = InspectClient {
        response: sample_releases_json().to_string(),
        has_auth: has_auth.clone(),
    };
    let gh = GitHub::new(client);
    let _ = gh.fetch_releases("test/repo", None).unwrap();

    assert!(!*has_auth.borrow());
}

#[test]
fn token_from_env_fallback() {
    use std::rc::Rc;

    #[derive(Clone)]
    struct InspectClient {
        response: String,
        got_auth: Rc<std::cell::RefCell<Option<String>>>,
    }

    impl HttpClient for InspectClient {
        fn get(&self, _url: &str, headers: &HeaderMap) -> releasekit::error::Result<Response> {
            for (k, v) in headers.iter() {
                if k == "Authorization" {
                    *self.got_auth.borrow_mut() = Some(v.to_string());
                }
            }
            Ok(Response {
                status: 200,
                body: self.response.clone(),
            })
        }
    }

    let got_auth = Rc::new(std::cell::RefCell::new(None));
    let client = InspectClient {
        response: sample_releases_json().to_string(),
        got_auth: got_auth.clone(),
    };

    // SAFETY: test-only, single-threaded, unique var names
    unsafe {
        std::env::set_var("RELEASEKIT_TEST_TOKEN_FALLBACK", "env-token-123");
    }
    let gh = GitHub::new(client).with_token_from_env(&[
        "RELEASEKIT_TEST_NONEXISTENT",
        "RELEASEKIT_TEST_TOKEN_FALLBACK",
    ]);
    let _ = gh.fetch_releases("test/repo", None).unwrap();
    unsafe {
        std::env::remove_var("RELEASEKIT_TEST_TOKEN_FALLBACK");
    }

    assert_eq!(got_auth.borrow().as_deref(), Some("Bearer env-token-123"));
}

#[test]
fn token_from_env_does_not_overwrite_explicit() {
    use std::rc::Rc;

    #[derive(Clone)]
    struct InspectClient {
        response: String,
        got_auth: Rc<std::cell::RefCell<Option<String>>>,
    }

    impl HttpClient for InspectClient {
        fn get(&self, _url: &str, headers: &HeaderMap) -> releasekit::error::Result<Response> {
            for (k, v) in headers.iter() {
                if k == "Authorization" {
                    *self.got_auth.borrow_mut() = Some(v.to_string());
                }
            }
            Ok(Response {
                status: 200,
                body: self.response.clone(),
            })
        }
    }

    let got_auth = Rc::new(std::cell::RefCell::new(None));
    let client = InspectClient {
        response: sample_releases_json().to_string(),
        got_auth: got_auth.clone(),
    };

    // SAFETY: test-only, single-threaded, unique var names
    unsafe {
        std::env::set_var("RELEASEKIT_TEST_TOKEN_OVERRIDE", "should-not-appear");
    }
    let gh = GitHub::new(client)
        .with_token("explicit-token")
        .with_token_from_env(&["RELEASEKIT_TEST_TOKEN_OVERRIDE"]);
    let _ = gh.fetch_releases("test/repo", None).unwrap();
    unsafe {
        std::env::remove_var("RELEASEKIT_TEST_TOKEN_OVERRIDE");
    }

    assert_eq!(got_auth.borrow().as_deref(), Some("Bearer explicit-token"));
}