use std::fmt;
use std::sync::LazyLock;
use serde::{Deserialize, Serialize};
pub const DEFAULT_BASE_URL: &str = "https://hotpath.rs";
pub static API_URL: LazyLock<String> =
LazyLock::new(|| normalize_base_url(std::env::var("HOTPATH_META_API_URL").ok()));
pub fn normalize_base_url(raw: Option<String>) -> String {
raw.map(|s| s.trim().trim_end_matches('/').to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidBenchmarkName;
impl fmt::Display for InvalidBenchmarkName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("use 1-64 chars from [A-Za-z0-9._-], not \".\" or \"..\"")
}
}
pub fn validate_benchmark_name(name: &str) -> Result<(), InvalidBenchmarkName> {
let valid_chars = name
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c));
if name.is_empty() || name.len() > 64 || !valid_chars || name == "." || name == ".." {
return Err(InvalidBenchmarkName);
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiErrorCode {
MissingToken,
InvalidToken,
GithubAuthorizationExpired,
BadRequest,
Forbidden,
NotFound,
MethodNotAllowed,
RateLimited,
InvalidPolicy,
PolicyRequired,
Internal,
#[default]
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiError {
pub error: String,
#[serde(default)]
pub code: ApiErrorCode,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommentOutcome {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UploadCreated {
pub id: String,
pub repository: String,
pub benchmark: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub baseline: Option<String>,
#[serde(default)]
pub comment: CommentOutcome,
pub verdict: Verdict,
pub policy_path: String,
pub policy_url: String,
pub dashboard_url: String,
}
pub const POLICY_MAX_BYTES: usize = 65536;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyValidation {
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DefaultPolicy {
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyProblem {
pub line: Option<u32>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyRejected {
pub error: String,
pub code: ApiErrorCode,
pub problems: Vec<PolicyProblem>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Verdict {
pub judged: bool,
pub regressed: bool,
pub regressions: u64,
pub improvements: u64,
pub budgets_broken: u64,
}
#[cfg(test)]
mod tests {
use crate::json::cloud_api::{
normalize_base_url, validate_benchmark_name, ApiError, ApiErrorCode, CommentOutcome,
PolicyProblem, PolicyRejected, PolicyValidation, UploadCreated, DEFAULT_BASE_URL,
};
#[test]
fn validate_benchmark_name_rule() {
for ok in [
"default",
"ci",
"timing-linux",
"api_latency",
"v0.25",
"timing.linux",
] {
assert!(
validate_benchmark_name(ok).is_ok(),
"{ok:?} should be valid"
);
}
for bad in ["a/b", "a b", "..", ".", "x?y", "ünïcode", ""] {
assert!(
validate_benchmark_name(bad).is_err(),
"{bad:?} should be invalid"
);
}
assert!(validate_benchmark_name(&"a".repeat(64)).is_ok());
assert!(validate_benchmark_name(&"a".repeat(65)).is_err());
let err = validate_benchmark_name("a/b").unwrap_err().to_string();
assert!(
err.contains("[A-Za-z0-9._-]"),
"message names the rule: {err}"
);
}
#[test]
fn normalize_base_url_rules() {
assert_eq!(normalize_base_url(None), DEFAULT_BASE_URL);
assert_eq!(normalize_base_url(Some(" ".into())), DEFAULT_BASE_URL);
assert_eq!(
normalize_base_url(Some(" http://localhost:3000/// ".into())),
"http://localhost:3000"
);
assert_eq!(
normalize_base_url(Some("https://staging.hotpath.rs".into())),
"https://staging.hotpath.rs"
);
}
#[test]
fn api_error_parses_known_unknown_and_missing_codes() {
let err: ApiError =
serde_json::from_str(r#"{"error":"nope","code":"invalid_token","later":1}"#).unwrap();
assert_eq!(err.error, "nope");
assert_eq!(err.code, ApiErrorCode::InvalidToken);
let newer: ApiError =
serde_json::from_str(r#"{"error":"nope","code":"quota_exceeded"}"#).unwrap();
assert_eq!(newer.code, ApiErrorCode::Unknown);
let bare: ApiError = serde_json::from_str(r#"{"error":"boom"}"#).unwrap();
assert_eq!(bare.code, ApiErrorCode::Unknown);
assert_eq!(
serde_json::to_string(&ApiError {
error: "Benchmark ci not found.".into(),
code: ApiErrorCode::NotFound,
})
.unwrap(),
r#"{"error":"Benchmark ci not found.","code":"not_found"}"#
);
}
const UPLOAD_DASHBOARD_URL: &str =
"https://hotpath.rs/app/repos/a/b/benchmarks/meta/reports/r1/diff";
const POLICY_URL: &str =
"https://github.com/a/b/blob/3f1c000000000000000000000000000000000000/hotpath/policy.toml";
#[test]
fn upload_created_parses_with_and_without_comment() {
let full: UploadCreated = serde_json::from_str(&format!(
r#"{{"id":"r1","repository":"a/b","benchmark":"meta","baseline":"r0",
"comment":{{"url":"https://github.com/c/1","error":"the report does not parse"}},
"verdict":{{"judged":true,"regressed":false,"regressions":0,"improvements":2,"budgets_broken":0}},
"policy_path":"hotpath/policy.toml","policy_url":"{POLICY_URL}",
"dashboard_url":"{UPLOAD_DASHBOARD_URL}","later":true}}"#
))
.unwrap();
assert_eq!(full.baseline.as_deref(), Some("r0"));
assert_eq!(full.comment.url.as_deref(), Some("https://github.com/c/1"));
assert_eq!(
full.comment.error.as_deref(),
Some("the report does not parse")
);
assert_eq!(full.verdict.improvements, 2);
let bare: UploadCreated = serde_json::from_str(&format!(
r#"{{"id":"r1","repository":"a/b","benchmark":"meta",
"verdict":{{"judged":false,"regressed":false,"regressions":0,"improvements":0,"budgets_broken":0}},
"policy_path":"hotpath/policy.toml","policy_url":"{POLICY_URL}",
"dashboard_url":"{UPLOAD_DASHBOARD_URL}"}}"#
))
.unwrap();
assert_eq!(bare.baseline, None);
assert_eq!(bare.comment, CommentOutcome::default());
}
#[test]
fn upload_created_names_the_policy_that_judged() {
let body = format!(
r#"{{"id":"r1","repository":"a/b","benchmark":"meta","comment":{{}},"verdict":{{"judged":true,"regressed":false,"regressions":0,"improvements":0,"budgets_broken":0}},"policy_path":"hotpath/policy.toml","policy_url":"{POLICY_URL}","dashboard_url":"{UPLOAD_DASHBOARD_URL}"}}"#
);
let created: UploadCreated = serde_json::from_str(&body).unwrap();
assert_eq!(created.policy_path, "hotpath/policy.toml");
assert_eq!(created.policy_url, POLICY_URL);
assert_eq!(serde_json::to_string(&created).unwrap(), body);
}
#[test]
fn upload_created_requires_the_verdict() {
let no_verdict = format!(
r#"{{"id":"r1","repository":"a/b","benchmark":"meta","dashboard_url":"{UPLOAD_DASHBOARD_URL}"}}"#
);
assert!(serde_json::from_str::<UploadCreated>(&no_verdict).is_err());
let no_url = r#"{"id":"r1","repository":"a/b","benchmark":"meta","verdict":{"judged":true,"regressed":false,"regressions":0,"improvements":0,"budgets_broken":0}}"#;
assert!(serde_json::from_str::<UploadCreated>(no_url).is_err());
}
#[test]
fn policy_validation_round_trips() {
let body = r#"{"source":"[functions]\n"}"#;
let request: PolicyValidation = serde_json::from_str(body).unwrap();
assert_eq!(
request,
PolicyValidation {
source: "[functions]\n".into(),
}
);
assert_eq!(serde_json::to_string(&request).unwrap(), body);
}
#[test]
fn policy_rejected_round_trips_and_parses_as_api_error() {
let body = r#"{"error":"The policy has 3 problems.","code":"invalid_policy","problems":[{"line":null,"message":"the policy is larger than 65536 bytes"},{"line":3,"message":"unknown key `functions.timing.min_percent`"},{"line":7,"message":"functions.alloc.min_percent_change must be between 0 and 1000, got 5000"}]}"#;
let rejected: PolicyRejected = serde_json::from_str(body).unwrap();
assert_eq!(
rejected,
PolicyRejected {
error: "The policy has 3 problems.".into(),
code: ApiErrorCode::InvalidPolicy,
problems: vec![
PolicyProblem {
line: None,
message: "the policy is larger than 65536 bytes".into(),
},
PolicyProblem {
line: Some(3),
message: "unknown key `functions.timing.min_percent`".into(),
},
PolicyProblem {
line: Some(7),
message:
"functions.alloc.min_percent_change must be between 0 and 1000, got 5000"
.into(),
},
],
}
);
assert_eq!(serde_json::to_string(&rejected).unwrap(), body);
let plain: ApiError = serde_json::from_str(body).unwrap();
assert_eq!(plain.code, ApiErrorCode::InvalidPolicy);
assert_eq!(plain.error, "The policy has 3 problems.");
}
}