1use std::collections::HashMap;
4use base64::Engine;
5use crate::{HarmontClient, Result};
6use crate::models::Build;
7
8#[derive(Debug, Clone)]
11pub struct NewBuild {
12 pub org: String,
14 pub pipeline: String,
16 pub branch: String,
18 pub commit: String,
20 pub message: Option<String>,
22 pub pipeline_ir: String,
24 pub source_tgz: Vec<u8>,
26 pub env: HashMap<String, String>,
28}
29
30impl HarmontClient {
31 pub async fn submit_build(&self, b: NewBuild) -> Result<Build> {
33 let source_b64 = base64::engine::general_purpose::STANDARD.encode(&b.source_tgz);
34 let url = format!(
35 "{}/api/v0/organizations/{}/pipelines/{}/builds",
36 self.base, b.org, b.pipeline
37 );
38 let body = serde_json::json!({
39 "branch": b.branch, "commit": b.commit, "message": b.message,
40 "source": "api", "pipeline_ir": b.pipeline_ir,
41 "source_b64": source_b64, "env": b.env,
42 });
43 let resp = self.http.post(&url).json(&body).send().await?;
44 self.parse_json(resp).await
45 }
46
47 pub async fn get_build(&self, org: &str, pipeline: &str, number: i64) -> Result<Build> {
49 let url = format!("{}/api/v0/organizations/{org}/pipelines/{pipeline}/builds/{number}", self.base);
50 let resp = self.http.get(&url).send().await?;
51 self.parse_json(resp).await
52 }
53
54 pub async fn list_jobs(&self, org: &str, pipeline: &str, number: i64)
56 -> Result<Vec<crate::models::Job>>
57 {
58 let url = format!("{}/api/v0/organizations/{org}/pipelines/{pipeline}/builds/{number}/jobs", self.base);
59 let resp = self.http.get(&url).send().await?;
60 #[derive(serde::Deserialize)]
61 struct Wrap { data: Vec<crate::models::Job> }
62 let w: Wrap = self.parse_json(resp).await?;
63 Ok(w.data)
64 }
65
66 pub async fn cancel_build(&self, org: &str, pipeline: &str, number: i64) -> Result<()> {
68 let url = format!("{}/api/v0/organizations/{org}/pipelines/{pipeline}/builds/{number}/cancel", self.base);
69 let resp = self.http.put(&url).send().await?;
70 let _: serde_json::Value = self.parse_json(resp).await?;
71 Ok(())
72 }
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78 use base64::Engine;
79 use crate::HarmontClient;
80 use wiremock::matchers::{method, path, body_partial_json};
81 use wiremock::{Mock, MockServer, ResponseTemplate};
82 use serde_json::json;
83
84 #[tokio::test]
85 async fn submit_build_posts_source_b64_and_pipeline_ir() {
86 let server = MockServer::start().await;
87 Mock::given(method("POST"))
88 .and(path("/api/v0/organizations/acme/pipelines/ci/builds"))
89 .and(body_partial_json(json!({
90 "branch": "feat/x", "commit": "abc123", "source": "api",
91 "pipeline_ir": "{\"version\":\"0\",\"steps\":[]}",
92 "source_b64": base64::engine::general_purpose::STANDARD.encode(b"fake-tarball"),
93 })))
94 .respond_with(ResponseTemplate::new(201).set_body_json(json!({
95 "number": 42, "state": "scheduled",
96 "branch": "feat/x", "commit": "abc123",
97 "message": null, "source": "api",
98 "created_at": "2026-06-04T00:00:00Z"
99 })))
100 .mount(&server).await;
101
102 let client = HarmontClient::with_base_url("hm_test", server.uri());
103 let req = NewBuild {
104 org: "acme".into(), pipeline: "ci".into(),
105 branch: "feat/x".into(), commit: "abc123".into(),
106 message: None,
107 pipeline_ir: r#"{"version":"0","steps":[]}"#.into(),
108 source_tgz: b"fake-tarball".to_vec(),
109 env: Default::default(),
110 };
111 let build = client.submit_build(req).await.expect("submit ok");
112 assert_eq!(build.number, 42);
113 assert_eq!(build.state.to_string(), "scheduled");
114 }
115
116 #[tokio::test]
117 async fn unauthorized_maps_cleanly() {
118 let server = MockServer::start().await;
119 Mock::given(method("GET")).respond_with(ResponseTemplate::new(401))
120 .mount(&server).await;
121 let client = HarmontClient::with_base_url("bad", server.uri());
122 let err = client.get_build("acme", "ci", 1).await.unwrap_err();
123 assert!(matches!(err, crate::HarmontError::Unauthorized));
124 }
125
126 #[tokio::test]
127 async fn rejected_build_maps_to_api_error() {
128 let server = MockServer::start().await;
129 Mock::given(method("POST"))
130 .respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
131 "error": {"code": "build_rejected", "message": "bad IR"}
132 }))).mount(&server).await;
133 let client = HarmontClient::with_base_url("hm_test", server.uri());
134 let req = NewBuild { org:"a".into(),pipeline:"c".into(),branch:"b".into(),
135 commit:"x".into(),message:None,pipeline_ir:"{}".into(),
136 source_tgz:vec![],env:Default::default() };
137 let err = client.submit_build(req).await.unwrap_err();
138 assert!(matches!(err, crate::HarmontError::Api { status: 422, .. }));
139 }
140
141 #[tokio::test]
142 async fn list_jobs_unwraps_data() {
143 let server = MockServer::start().await;
144 Mock::given(method("GET"))
145 .and(path("/api/v0/organizations/acme/pipelines/ci/builds/42/jobs"))
146 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
147 "data": [{"id":"00000000-0000-0000-0000-000000000001","state":"running",
148 "name":"build","depends_on":[],"created_at":"2026-06-04T00:00:00Z",
149 "soft_failed": false}]
150 }))).mount(&server).await;
151 let client = HarmontClient::with_base_url("hm_test", server.uri());
152 let jobs = client.list_jobs("acme","ci",42).await.expect("ok");
153 assert_eq!(jobs.len(), 1);
154 }
155}