cargo-port 0.2.0

A TUI for inspecting and managing Rust projects
use std::time::SystemTime;
use std::time::UNIX_EPOCH;

use reqwest::Error;
use reqwest::StatusCode;
use reqwest::header::HeaderMap;
use serde_json::Value;

use super::ServiceKind;
use super::ServiceSignal;
use super::constants::GITHUB_CORE_BUCKET;
use super::constants::GITHUB_GRAPHQL_BUCKET;
use super::constants::GRAPHQL_RATE_LIMITED_ERROR_TYPE;
use super::constants::GRAPHQL_RESPONSE_ERRORS_KEY;
use super::constants::GRAPHQL_RESPONSE_TYPE_KEY;
use super::constants::RATE_LIMIT_LIMIT_HEADER;
use super::constants::RATE_LIMIT_LIMIT_KEY;
use super::constants::RATE_LIMIT_REMAINING_HEADER;
use super::constants::RATE_LIMIT_REMAINING_KEY;
use super::constants::RATE_LIMIT_RESET_HEADER;
use super::constants::RATE_LIMIT_RESET_KEY;
use super::constants::RATE_LIMIT_RESOURCE_HEADER;
use super::constants::RATE_LIMIT_RESOURCES_KEY;
use super::constants::RATE_LIMIT_USED_HEADER;
use super::constants::RATE_LIMIT_USED_KEY;
pub(super) use super::constants::SYNTHETIC_RATE_LIMIT_SECS;

/// Which GitHub rate-limit bucket a response belongs to. The REST and
/// GraphQL APIs share `api.github.com` but track their quotas
/// independently, so detection and display must keep them separate.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RateLimitBucket {
    Core,
    GraphQl,
}

/// a single rate-limit bucket. `reset_at` is a Unix epoch
/// timestamp; `None` means the response did not include a reset header.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct RateLimitQuota {
    pub limit:     u64,
    pub used:      u64,
    pub remaining: u64,
    pub reset_at:  Option<u64>,
}

/// Live rate-limit state for both REST and GraphQL buckets. Either
/// field is `None` until a real response or `/rate_limit` poll
/// populates it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct GitHubRateLimit {
    pub core:    Option<RateLimitQuota>,
    pub graphql: Option<RateLimitQuota>,
}

/// Read `X-RateLimit-*` headers off a GitHub response and identify which
/// bucket the response counted against. Returns `None` if the bucket
/// header is missing or names a resource we don't track (`search`,
/// `integration_manifest`, etc.).
pub(crate) fn parse_rate_limit_headers(
    headers: &HeaderMap,
) -> Option<(RateLimitBucket, RateLimitQuota)> {
    let resource = headers.get(RATE_LIMIT_RESOURCE_HEADER)?.to_str().ok()?;
    let bucket = match resource {
        GITHUB_CORE_BUCKET => RateLimitBucket::Core,
        GITHUB_GRAPHQL_BUCKET => RateLimitBucket::GraphQl,
        _ => return None,
    };
    let parse = |name: &str| -> Option<u64> { headers.get(name)?.to_str().ok()?.parse().ok() };
    let limit = parse(RATE_LIMIT_LIMIT_HEADER)?;
    let used = parse(RATE_LIMIT_USED_HEADER)?;
    let remaining = parse(RATE_LIMIT_REMAINING_HEADER)?;
    let reset_at = parse(RATE_LIMIT_RESET_HEADER);
    Some((
        bucket,
        RateLimitQuota {
            limit,
            used,
            remaining,
            reset_at,
        },
    ))
}

/// Parse a `/rate_limit` JSON response. Missing buckets stay `None` so
/// the caller can merge selectively.
pub(crate) fn parse_rate_limit_response(value: &Value) -> GitHubRateLimit {
    let resources = value.get(RATE_LIMIT_RESOURCES_KEY);
    let bucket = |name: &str| -> Option<RateLimitQuota> {
        let entry = resources?.get(name)?;
        Some(RateLimitQuota {
            limit:     entry.get(RATE_LIMIT_LIMIT_KEY)?.as_u64()?,
            used:      entry.get(RATE_LIMIT_USED_KEY)?.as_u64()?,
            remaining: entry.get(RATE_LIMIT_REMAINING_KEY)?.as_u64()?,
            reset_at:  entry
                .get(RATE_LIMIT_RESET_KEY)
                .and_then(serde_json::Value::as_u64),
        })
    };
    GitHubRateLimit {
        core:    bucket(GITHUB_CORE_BUCKET),
        graphql: bucket(GITHUB_GRAPHQL_BUCKET),
    }
}

/// True for the two REST forms GitHub uses for rate-limit refusals:
/// `429 Too Many Requests`, or `403 Forbidden` with
/// `X-RateLimit-Remaining: 0` (the secondary-rate-limit / abuse-detection
/// form). A bare 403 is auth-related and not rate-limit.
pub(crate) fn github_is_rate_limited(status: StatusCode, headers: &HeaderMap) -> bool {
    if status == StatusCode::TOO_MANY_REQUESTS {
        return true;
    }
    if status == StatusCode::FORBIDDEN {
        return headers
            .get(RATE_LIMIT_REMAINING_HEADER)
            .and_then(|value| value.to_str().ok())
            .and_then(|text| text.parse::<u64>().ok())
            .is_some_and(|remaining| remaining == 0);
    }
    false
}

/// True when a GraphQL response body carries an `errors[].type` of
/// `RATE_LIMITED`. GraphQL returns HTTP 200 on rate-limit, so
/// status-based detection alone is not enough for that endpoint.
pub(crate) fn graphql_body_is_rate_limited(body: &Value) -> bool {
    body.get(GRAPHQL_RESPONSE_ERRORS_KEY)
        .and_then(serde_json::Value::as_array)
        .is_some_and(|errors| {
            errors.iter().any(|err| {
                err.get(GRAPHQL_RESPONSE_TYPE_KEY)
                    .and_then(serde_json::Value::as_str)
                    .is_some_and(|t| t == GRAPHQL_RATE_LIMITED_ERROR_TYPE)
            })
        })
}

pub(super) fn classify_network_error(service: ServiceKind, error: &Error) -> Option<ServiceSignal> {
    if error.is_connect() || error.is_timeout() {
        Some(ServiceSignal::Unreachable(service))
    } else {
        None
    }
}

pub(super) fn now_epoch_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |d| d.as_secs())
}