use std::collections::HashMap;
use base64::Engine;
use crate::{HarmontClient, Result};
use crate::models::Build;
#[derive(Debug, Clone)]
pub struct NewBuild {
pub org: String,
pub pipeline: String,
pub branch: String,
pub commit: String,
pub message: Option<String>,
pub pipeline_ir: String,
pub source_tgz: Vec<u8>,
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct NewRepoBuild {
pub org: String,
pub repo_name: String,
pub source_slug: String,
pub branch: String,
pub commit: String,
pub message: Option<String>,
pub pipeline_ir: String,
pub source_tgz: Vec<u8>,
pub env: HashMap<String, String>,
}
impl HarmontClient {
pub async fn submit_build(&self, b: NewBuild) -> Result<Build> {
let source_b64 = base64::engine::general_purpose::STANDARD.encode(&b.source_tgz);
let url = format!(
"{}/api/v0/organizations/{}/pipelines/{}/builds",
self.base, b.org, b.pipeline
);
let body = serde_json::json!({
"branch": b.branch, "commit": b.commit, "message": b.message,
"source": "api", "pipeline_ir": b.pipeline_ir,
"source_b64": source_b64, "env": b.env,
});
let resp = self.http.post(&url).json(&body).send().await?;
self.parse_json(resp).await
}
pub async fn submit_repo_build(&self, b: NewRepoBuild) -> Result<Build> {
let source_b64 = base64::engine::general_purpose::STANDARD.encode(&b.source_tgz);
let url = format!("{}/api/v0/organizations/{}/builds", self.base, b.org);
let body = serde_json::json!({
"repo_name": b.repo_name, "source_slug": b.source_slug,
"branch": b.branch, "commit": b.commit, "message": b.message,
"source": "api", "pipeline_ir": b.pipeline_ir,
"source_b64": source_b64, "env": b.env,
});
let resp = self.http.post(&url).json(&body).send().await?;
self.parse_json_structured(resp).await
}
pub async fn get_build(&self, org: &str, pipeline: &str, number: i64) -> Result<Build> {
let url = format!("{}/api/v0/organizations/{org}/pipelines/{pipeline}/builds/{number}", self.base);
let resp = self.http.get(&url).send().await?;
self.parse_json(resp).await
}
pub async fn list_jobs(&self, org: &str, pipeline: &str, number: i64)
-> Result<Vec<crate::models::Job>>
{
let url = format!("{}/api/v0/organizations/{org}/pipelines/{pipeline}/builds/{number}/jobs", self.base);
let resp = self.http.get(&url).send().await?;
#[derive(serde::Deserialize)]
struct Wrap { data: Vec<crate::models::Job> }
let w: Wrap = self.parse_json(resp).await?;
Ok(w.data)
}
pub async fn cancel_build(&self, org: &str, pipeline: &str, number: i64) -> Result<()> {
let url = format!("{}/api/v0/organizations/{org}/pipelines/{pipeline}/builds/{number}/cancel", self.base);
let resp = self.http.put(&url).send().await?;
let _: serde_json::Value = self.parse_json(resp).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use crate::HarmontClient;
use wiremock::matchers::{method, path, body_partial_json};
use wiremock::{Mock, MockServer, ResponseTemplate};
use serde_json::json;
#[tokio::test]
async fn submit_build_posts_source_b64_and_pipeline_ir() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v0/organizations/acme/pipelines/ci/builds"))
.and(body_partial_json(json!({
"branch": "feat/x", "commit": "abc123", "source": "api",
"pipeline_ir": "{\"version\":\"0\",\"steps\":[]}",
"source_b64": base64::engine::general_purpose::STANDARD.encode(b"fake-tarball"),
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"number": 42, "state": "scheduled", "pipeline_slug": "ci",
"branch": "feat/x", "commit": "abc123",
"message": null, "source": "api",
"created_at": "2026-06-04T00:00:00Z"
})))
.mount(&server).await;
let client = HarmontClient::with_base_url("hm_test", server.uri());
let req = NewBuild {
org: "acme".into(), pipeline: "ci".into(),
branch: "feat/x".into(), commit: "abc123".into(),
message: None,
pipeline_ir: r#"{"version":"0","steps":[]}"#.into(),
source_tgz: b"fake-tarball".to_vec(),
env: Default::default(),
};
let build = client.submit_build(req).await.expect("submit ok");
assert_eq!(build.number, 42);
assert_eq!(build.state.to_string(), "scheduled");
}
#[tokio::test]
async fn unauthorized_maps_cleanly() {
let server = MockServer::start().await;
Mock::given(method("GET")).respond_with(ResponseTemplate::new(401))
.mount(&server).await;
let client = HarmontClient::with_base_url("bad", server.uri());
let err = client.get_build("acme", "ci", 1).await.unwrap_err();
assert!(matches!(err, crate::HarmontError::Unauthorized));
}
#[tokio::test]
async fn rejected_build_maps_to_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
"error": {"code": "build_rejected", "message": "bad IR"}
}))).mount(&server).await;
let client = HarmontClient::with_base_url("hm_test", server.uri());
let req = NewBuild { org:"a".into(),pipeline:"c".into(),branch:"b".into(),
commit:"x".into(),message:None,pipeline_ir:"{}".into(),
source_tgz:vec![],env:Default::default() };
let err = client.submit_build(req).await.unwrap_err();
assert!(matches!(err, crate::HarmontError::Api { status: 422, .. }));
}
#[tokio::test]
async fn submit_repo_build_posts_to_org_builds_with_repo_and_source() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v0/organizations/acme/builds"))
.and(body_partial_json(json!({
"repo_name": "harmont-dev/acme", "source_slug": "ci",
"branch": "main", "commit": "abc123", "source": "api",
"pipeline_ir": "{\"version\":\"0\",\"steps\":[]}",
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"number": 7, "state": "scheduled", "pipeline_slug": "harmont-dev-acme-ci",
"branch": "main", "commit": "abc123", "message": null, "source": "api",
"created_at": "2026-06-10T00:00:00Z"
})))
.mount(&server).await;
let client = HarmontClient::with_base_url("hm_test", server.uri());
let req = NewRepoBuild {
org: "acme".into(), repo_name: "harmont-dev/acme".into(), source_slug: "ci".into(),
branch: "main".into(), commit: "abc123".into(), message: None,
pipeline_ir: r#"{"version":"0","steps":[]}"#.into(),
source_tgz: b"fake-tarball".to_vec(), env: Default::default(),
};
let build = client.submit_repo_build(req).await.expect("submit ok");
assert_eq!(build.number, 7);
assert_eq!(build.pipeline_slug, "harmont-dev-acme-ci");
}
#[tokio::test]
async fn submit_repo_build_404_surfaces_structured_code() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
"error": {"code": "pipeline_not_found", "message": "No pipeline `ci` found …"}
}))).mount(&server).await;
let client = HarmontClient::with_base_url("hm_test", server.uri());
let req = NewRepoBuild {
org: "a".into(), repo_name: "o/r".into(), source_slug: "ci".into(),
branch: "b".into(), commit: "x".into(), message: None,
pipeline_ir: "{}".into(), source_tgz: vec![], env: Default::default(),
};
let err = client.submit_repo_build(req).await.unwrap_err();
assert!(matches!(
err,
crate::HarmontError::Api { status: 404, ref code, .. } if code == "pipeline_not_found"
));
}
#[tokio::test]
async fn list_jobs_unwraps_data() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/organizations/acme/pipelines/ci/builds/42/jobs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id":"00000000-0000-0000-0000-000000000001","state":"running",
"name":"build","depends_on":[],"created_at":"2026-06-04T00:00:00Z",
"soft_failed": false}]
}))).mount(&server).await;
let client = HarmontClient::with_base_url("hm_test", server.uri());
let jobs = client.list_jobs("acme","ci",42).await.expect("ok");
assert_eq!(jobs.len(), 1);
}
}