use std::time::Duration;
use anyhow::{Context, Result};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, IF_NONE_MATCH, USER_AGENT};
use reqwest::{Client as HttpClient, StatusCode};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use time::OffsetDateTime;
use crate::storage::Cache;
pub const SCORECARD_API_BASE: &str = "https://api.scorecard.dev";
const TTL_SCORECARD: Duration = Duration::from_secs(7 * 24 * 3600);
#[derive(Debug, Error)]
pub enum ScorecardError {
#[error("scorecard.dev returned {status}: {body}")]
Other { status: u16, body: String },
}
#[derive(Debug, Clone)]
pub struct Client {
http: HttpClient,
base_url: String,
cache: Cache,
}
impl Client {
#[must_use]
pub fn new(http: HttpClient, cache: Cache) -> Self {
Self {
http,
base_url: SCORECARD_API_BASE.to_string(),
cache,
}
}
#[must_use]
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
pub async fn get(&self, owner: &str, repo: &str) -> Result<Option<ScorecardReport>> {
let key = format!("scorecard:projects/github.com/{owner}/{repo}");
let path = format!("/projects/github.com/{owner}/{repo}");
let body = match self.fetch_json(&key, &path, TTL_SCORECARD).await? {
Some(b) => b,
None => return Ok(None),
};
let parsed: ScorecardReport =
serde_json::from_slice(&body).context("parse ScorecardReport")?;
Ok(Some(parsed))
}
async fn fetch_json(
&self,
cache_key: &str,
path: &str,
ttl: Duration,
) -> Result<Option<Vec<u8>>> {
let cached = self.cache.get(cache_key)?;
if let Some(entry) = &cached {
if !entry.is_stale() {
return Ok(Some(entry.body.clone()));
}
}
let cached_etag = cached.as_ref().and_then(|e| e.etag.clone());
let cached_body = cached.as_ref().map(|e| e.body.clone());
let url = format!("{}{}", self.base_url, path);
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("repo-trust"));
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
if let Some(e) = &cached_etag {
headers.insert(IF_NONE_MATCH, HeaderValue::from_str(e)?);
}
let resp = self
.http
.get(&url)
.headers(headers)
.send()
.await
.with_context(|| format!("GET {url}"))?;
match resp.status() {
StatusCode::NOT_MODIFIED => {
let body = cached_body
.ok_or_else(|| anyhow::anyhow!("304 received without cached body"))?;
self.cache
.put(cache_key, cached_etag.as_deref(), &body, ttl)?;
Ok(Some(body))
},
StatusCode::OK => {
let new_etag = resp
.headers()
.get("etag")
.and_then(|h| h.to_str().ok())
.map(str::to_string);
let body = resp.bytes().await?;
self.cache.put(cache_key, new_etag.as_deref(), &body, ttl)?;
Ok(Some(body.to_vec()))
},
StatusCode::NOT_FOUND => Ok(None),
s => {
let body = resp.text().await.unwrap_or_default();
Err(ScorecardError::Other {
status: s.as_u16(),
body,
}
.into())
},
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ScorecardReport {
#[serde(
deserialize_with = "deserialize_scorecard_date",
serialize_with = "time::serde::iso8601::serialize"
)]
pub date: OffsetDateTime,
pub repo: ScorecardRepoRef,
pub score: f64,
pub checks: Vec<CheckResult>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ScorecardRepoRef {
pub name: String,
pub commit: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CheckResult {
pub name: String,
pub score: i32,
pub reason: String,
#[serde(default)]
pub documentation: serde_json::Value,
}
fn deserialize_scorecard_date<'de, D>(de: D) -> Result<OffsetDateTime, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
use time::format_description::well_known::{Iso8601, Rfc3339};
let raw = String::deserialize(de)?;
if let Ok(dt) = OffsetDateTime::parse(&raw, &Rfc3339) {
return Ok(dt);
}
if let Ok(dt) = OffsetDateTime::parse(&raw, &Iso8601::DEFAULT) {
return Ok(dt);
}
if let Ok(d) = time::Date::parse(
&raw,
time::macros::format_description!("[year]-[month]-[day]"),
) {
return Ok(d.midnight().assume_utc());
}
Err(D::Error::custom(format!(
"scorecard.dev `date` field is not a recognised ISO-8601 / RFC 3339 / \
date-only string: {raw:?}"
)))
}
#[cfg(test)]
mod date_parsing_tests {
use super::*;
const FIXTURES: &[&str] = &[
concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/scorecard/clap-rs_clap.json"
),
concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/scorecard/octocat_hello-world.json"
),
concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/scorecard/rust-lang_rust.json"
),
concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/scorecard/tokio-rs_tokio.json"
),
];
#[test]
fn parses_rfc3339_no_fractional_seconds() {
let s = r#"{
"date":"2026-04-30T00:00:00Z",
"repo":{"name":"github.com/o/r","commit":"abc"},
"score":7.5,
"checks":[]
}"#;
let r: ScorecardReport = serde_json::from_str(s).unwrap();
assert_eq!(r.score, 7.5);
}
#[test]
fn parses_rfc3339_with_explicit_offset() {
let s = r#"{
"date":"2026-04-30T00:00:00+00:00",
"repo":{"name":"github.com/o/r","commit":"abc"},
"score":7.5,
"checks":[]
}"#;
serde_json::from_str::<ScorecardReport>(s).unwrap();
}
#[test]
fn parses_full_extended_iso8601_with_nanos() {
let s = r#"{
"date":"2026-04-30T00:00:00.123456789Z",
"repo":{"name":"github.com/o/r","commit":"abc"},
"score":7.5,
"checks":[]
}"#;
serde_json::from_str::<ScorecardReport>(s).unwrap();
}
#[test]
fn parses_date_only_as_midnight_utc() {
let s = r#"{
"date":"2026-04-30",
"repo":{"name":"github.com/o/r","commit":"abc"},
"score":7.5,
"checks":[]
}"#;
let r: ScorecardReport = serde_json::from_str(s).unwrap();
assert_eq!(r.date.hour(), 0);
assert_eq!(r.date.minute(), 0);
assert_eq!(r.date.second(), 0);
}
#[test]
fn rejects_garbage() {
let s = r#"{
"date":"not a date",
"repo":{"name":"github.com/o/r","commit":"abc"},
"score":7.5,
"checks":[]
}"#;
assert!(serde_json::from_str::<ScorecardReport>(s).is_err());
}
#[test]
fn real_world_fixtures_round_trip() {
for path in FIXTURES {
let body =
std::fs::read(path).unwrap_or_else(|e| panic!("missing fixture {path}: {e}"));
if !body.starts_with(b"{") {
continue;
}
let r: ScorecardReport = serde_json::from_slice(&body)
.unwrap_or_else(|e| panic!("failed to parse {path}: {e}"));
assert!(
(0.0..=10.0).contains(&r.score),
"score out of range in {path}: {}",
r.score
);
}
}
}