use std::time::Duration;
use serde::Deserialize;
use crate::report::RunRecord;
const EVALFORGE_BASE_URL: &str = "https://evalforge.ai";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Upload {
pub project_id: String,
pub api_key: String,
pub model: String,
pub backend: String,
pub cases_dir: String,
base_url: String,
}
impl Upload {
pub(crate) fn new(project_id: String, api_key: String) -> Self {
Self {
project_id,
api_key,
model: String::new(),
backend: String::new(),
cases_dir: String::new(),
base_url: EVALFORGE_BASE_URL.to_owned(),
}
}
#[cfg(test)]
pub(crate) fn with_base_url(mut self, base_url: String) -> Self {
self.base_url = base_url;
self
}
}
fn endpoint_url(base_url: &str, project_id: &str) -> String {
let base = base_url.trim_end_matches('/');
format!("{base}/api/projects/{project_id}/runs")
}
#[derive(Debug, Clone, Deserialize)]
pub struct UploadResponse {
#[serde(rename = "runId")]
pub run_id: String,
#[serde(default)]
pub deduped: bool,
}
pub fn upload_record(upload: &Upload, record: &RunRecord) -> anyhow::Result<UploadResponse> {
let url = endpoint_url(&upload.base_url, &upload.project_id);
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(30))
.build();
let response = agent
.post(&url)
.set("Authorization", &format!("Bearer {}", upload.api_key))
.set("Content-Type", "application/json")
.send_json(record);
match response {
Ok(response) => response
.into_json::<UploadResponse>()
.map_err(|e| anyhow::anyhow!("parsing upload response: {e}")),
Err(ureq::Error::Status(code, response)) => {
let body = response.into_string().unwrap_or_default();
Err(anyhow::anyhow!(
"evalforge upload failed: HTTP {code}: {body}"
))
}
Err(ureq::Error::Transport(t)) => {
Err(anyhow::anyhow!("evalforge upload transport error: {t}"))
}
}
}
#[cfg(test)]
mod tests {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use super::*;
use crate::report::{EvalReport, RunRecord};
#[test]
fn endpoint_url_is_evalforge_projects_runs() {
assert_eq!(
endpoint_url("https://evalforge.ai", "abc"),
"https://evalforge.ai/api/projects/abc/runs"
);
}
#[test]
fn endpoint_url_trims_trailing_slash() {
assert_eq!(
endpoint_url("https://evalforge.ai/", "abc"),
"https://evalforge.ai/api/projects/abc/runs"
);
}
#[test]
fn upload_record_posts_to_evalforge_and_parses_201() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
let addr = listener.local_addr().expect("local_addr");
let handle = thread::spawn(move || {
let (mut stream, _peer) = listener.accept().expect("accept connection");
let mut buf: Vec<u8> = Vec::new();
let mut chunk = [0u8; 1024];
let header_end = loop {
if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") {
break pos + 4;
}
let n = stream.read(&mut chunk).expect("read request headers");
if n == 0 {
panic!("connection closed before headers completed");
}
buf.extend_from_slice(&chunk[..n]);
};
let header_text = String::from_utf8_lossy(&buf[..header_end]).into_owned();
let content_length = header_text
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.trim().eq_ignore_ascii_case("content-length") {
value.trim().parse::<usize>().ok()
} else {
None
}
})
.unwrap_or(0);
let mut body = buf[header_end..].to_vec();
while body.len() < content_length {
let n = stream.read(&mut chunk).expect("read request body");
if n == 0 {
break;
}
body.extend_from_slice(&chunk[..n]);
}
let request_line = header_text.lines().next().unwrap_or_default().to_owned();
let method_path_ok = request_line.starts_with("POST ")
&& request_line.contains("/api/projects/proj-xyz/runs");
let auth_ok = header_text.lines().any(|line| {
line.split_once(':')
.map(|(name, value)| {
name.trim().eq_ignore_ascii_case("authorization")
&& value.trim() == "Bearer sk-eval-testkey"
})
.unwrap_or(false)
});
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("\"model\":\"mock-model\""),
"request body should be the serialized RunRecord, got: {body_text}"
);
let json = r#"{"runId":"run_abc123","deduped":false}"#;
let response = format!(
"HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
json.len(),
json
);
stream
.write_all(response.as_bytes())
.expect("write response");
stream.flush().expect("flush response");
(method_path_ok, auth_ok)
});
let upload = Upload::new("proj-xyz".into(), "sk-eval-testkey".into())
.with_base_url(format!("http://{addr}"));
let report = EvalReport::new(Vec::new(), 0.0, "local: m".into(), "sys".into());
let record = RunRecord {
model: "mock-model".into(),
timestamp_display: "2026-06-18 14:03:21".into(),
timestamp_file: "20260618-140321".into(),
backend: "local".into(),
cases_dir: "cases".into(),
system_prompt: "sys".into(),
report,
};
let resp = upload_record(&upload, &record).expect("upload");
assert_eq!(resp.run_id, "run_abc123");
assert!(!resp.deduped);
let (method_path_ok, auth_ok) = handle.join().expect("handler thread");
assert!(method_path_ok, "POST to /api/projects/proj-xyz/runs");
assert!(
auth_ok,
"Authorization: Bearer sk-eval-testkey header present"
);
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack
.windows(needle.len())
.position(|window| window == needle)
}
}