1use crate::config::{AuthConfig, IfExistsAction, PublishConfig, RegistryConfig};
2use anyhow::Result;
3use reqwest::{
4 header::{HeaderMap, HeaderValue, AUTHORIZATION},
5 Client,
6};
7use semver::Version;
8use serde::Deserialize;
9use serde_json::{json, Value};
10use std::env;
11
12fn suggest_version_bump(version: &str) -> String {
14 if let Ok(parsed_version) = Version::parse(version) {
15 let mut new_version = parsed_version.clone();
17 new_version.patch += 1;
18 new_version.to_string()
19 } else {
20 if let Some(last_dot) = version.rfind('.') {
22 let (prefix, suffix) = version.split_at(last_dot + 1);
23 if let Ok(patch) = suffix.parse::<u64>() {
24 format!("{}{}", prefix, patch + 1)
25 } else {
26 format!("{version}.1")
27 }
28 } else {
29 format!("{version}.1")
30 }
31 }
32}
33
34#[allow(dead_code)]
35#[derive(Deserialize, Debug)]
36#[serde(rename_all = "camelCase")]
37pub struct ArtifactMetadata {
38 pub artifact_id: String,
39 pub artifact_type: String,
40 #[serde(default, alias = "groupId", alias = "group")]
41 pub group_id: Option<String>,
42}
43
44pub struct RegistryClient {
45 #[allow(dead_code)]
46 pub name: String,
47 pub base_url: String,
48 pub client: Client,
49}
50
51impl RegistryClient {
52 pub fn new(cfg: &RegistryConfig) -> Result<Self> {
53 let mut headers = HeaderMap::new();
54 match &cfg.auth {
55 AuthConfig::None => {}
56 AuthConfig::Basic {
57 username,
58 password_env,
59 } => {
60 let pw = env::var(password_env)?;
61 let token = base64::encode_config(format!("{username}:{pw}"), base64::STANDARD);
62 let hv = HeaderValue::from_str(&format!("Basic {token}"))?;
63 headers.insert(AUTHORIZATION, hv);
64 }
65 AuthConfig::Token { token_env } => {
66 let tok = env::var(token_env)?;
67 let hv = HeaderValue::from_str(&tok)?;
68 headers.insert(AUTHORIZATION, hv);
69 }
70 AuthConfig::Bearer { token_env } => {
71 let tok = env::var(token_env)?;
72 let hv = HeaderValue::from_str(&format!("Bearer {tok}"))?;
73 headers.insert(AUTHORIZATION, hv);
74 }
75 }
76
77 let client = Client::builder().default_headers(headers).build()?;
78 Ok(RegistryClient {
79 name: cfg.name.clone(),
80 base_url: cfg.url.clone(),
81 client,
82 })
83 }
84
85 pub async fn list_versions(&self, group_id: &str, artifact_id: &str) -> Result<Vec<Version>> {
87 let url = format!(
88 "{}/apis/registry/v3/groups/{}/artifacts/{}/versions",
89 self.base_url, group_id, artifact_id
90 );
91 let resp = self.client.get(&url).send().await?.error_for_status()?;
92 #[derive(Deserialize)]
93 struct ApiResponse {
94 #[allow(dead_code)]
95 count: usize,
96 versions: Vec<ApiVersion>,
97 }
98
99 #[derive(Deserialize)]
100 struct ApiVersion {
101 version: String,
102 }
103
104 let api_response: ApiResponse = resp.json().await?;
105 let mut semver_versions = Vec::new();
106 for v in api_response.versions {
107 if let Ok(parsed) = Version::parse(&v.version) {
108 semver_versions.push(parsed);
109 }
110 }
111 Ok(semver_versions)
112 }
113
114 pub fn get_download_url(&self, group_id: &str, artifact_id: &str, version: &Version) -> String {
115 format!(
116 "{}/apis/registry/v3/groups/{}/artifacts/{}/versions/{}/content",
117 self.base_url, group_id, artifact_id, version
118 )
119 }
120
121 pub async fn download(
123 &self,
124 group_id: &str,
125 artifact_id: &str,
126 version: &Version,
127 ) -> Result<bytes::Bytes> {
128 let url = self.get_download_url(group_id, artifact_id, version);
129 let resp = self.client.get(&url).send().await?.error_for_status()?;
130 Ok(resp.bytes().await?)
131 }
132
133 pub async fn list_groups(&self) -> Result<Vec<String>> {
135 let url = format!("{}/apis/registry/v3/groups", self.base_url);
136 let resp = self.client.get(&url).send().await?.error_for_status()?;
137
138 #[derive(Deserialize)]
139 struct ApiResponse {
140 #[allow(dead_code)]
141 count: usize,
142 groups: Vec<ApiGroup>,
143 }
144
145 #[derive(Deserialize)]
146 struct ApiGroup {
147 #[serde(rename = "groupId")]
148 group_id: String,
149 }
150
151 let api_response: ApiResponse = resp.json().await?;
152 Ok(api_response
153 .groups
154 .into_iter()
155 .map(|g| g.group_id)
156 .collect())
157 }
158
159 pub async fn list_artifacts(&self, group_id: &str) -> Result<Vec<String>> {
161 let url = format!(
162 "{}/apis/registry/v3/groups/{}/artifacts",
163 self.base_url, group_id
164 );
165 let resp = self.client.get(&url).send().await?.error_for_status()?;
166
167 #[derive(Deserialize)]
168 struct ApiResponse {
169 #[allow(dead_code)]
170 count: usize,
171 artifacts: Vec<ApiArtifact>,
172 }
173
174 #[derive(Deserialize)]
175 struct ApiArtifact {
176 #[serde(rename = "artifactId")]
177 artifact_id: String,
178 }
179
180 let api_response: ApiResponse = resp.json().await?;
181 Ok(api_response
182 .artifacts
183 .into_iter()
184 .map(|a| a.artifact_id)
185 .collect())
186 }
187
188 pub async fn artifact_exists(&self, group_id: &str, artifact_id: &str) -> Result<bool> {
190 let url = format!(
191 "{}/apis/registry/v3/groups/{}/artifacts/{}",
192 self.base_url, group_id, artifact_id
193 );
194
195 match self.client.get(&url).send().await {
196 Ok(resp) => Ok(resp.status().is_success()),
197 Err(_) => Ok(false),
198 }
199 }
200
201 pub async fn get_artifact_metadata(
203 &self,
204 group_id: &str,
205 artifact_id: &str,
206 ) -> Result<ArtifactMetadata> {
207 let url = format!(
208 "{}/apis/registry/v3/groups/{}/artifacts/{}",
209 self.base_url, group_id, artifact_id
210 );
211 let resp = self.client.get(&url).send().await?.error_for_status()?;
212
213 let mut metadata: ArtifactMetadata = resp.json().await?;
214 if metadata.group_id.is_none() {
216 metadata.group_id = Some(group_id.to_string());
217 }
218 Ok(metadata)
219 }
220
221 pub async fn publish_artifact(&self, publish: &PublishConfig, content: &str) -> Result<()> {
223 let group_id = publish.resolved_group_id();
224 let artifact_id = publish.resolved_artifact_id();
225 let content_type = publish.resolved_content_type();
226 let artifact_type = publish.resolved_artifact_type();
227
228 if self
230 .version_exists(&group_id, &artifact_id, &publish.version)
231 .await?
232 {
233 match self
235 .get_version_content(&group_id, &artifact_id, &publish.version)
236 .await
237 {
238 Ok(existing_content) => {
239 if existing_content.trim() == content.trim() {
240 println!(
241 " ℹ️ Version {}@{} already published with identical content",
242 artifact_id, publish.version
243 );
244 return Ok(());
245 } else {
246 println!(
248 " ⚠️ Version {}@{} already exists with different content",
249 artifact_id, publish.version
250 );
251 println!(
252 " Consider bumping the version (e.g., {}) to publish the updated content",
253 suggest_version_bump(&publish.version)
254 );
255 anyhow::bail!("Cannot publish different content with same version");
256 }
257 }
258 Err(_) => {
259 println!(
261 " ⚠️ Version {}@{} exists but content comparison failed, proceeding with publish",
262 artifact_id, publish.version
263 );
264 }
265 }
266 }
267
268 let references: Vec<Value> = publish
270 .references
271 .iter()
272 .map(|r| {
273 json!({
274 "groupId": r.resolved_group_id(),
275 "artifactId": r.resolved_artifact_id(),
276 "version": r.version,
277 "name": r.name_alias.as_deref().unwrap_or(&r.resolved_artifact_id())
278 })
279 })
280 .collect();
281
282 let artifact_exists = self.artifact_exists(&group_id, &artifact_id).await?;
284
285 if artifact_exists {
286 let version_payload = json!({
288 "version": publish.version,
289 "content": {
290 "content": content,
291 "contentType": content_type,
292 "references": references
293 },
294 "name": &publish.name,
295 "description": publish.description.as_deref().unwrap_or(""),
296 "labels": {}
297 });
298
299 let url = format!(
300 "{}/apis/registry/v3/groups/{}/artifacts/{}/versions",
301 self.base_url.trim_end_matches('/'),
302 group_id,
303 artifact_id
304 );
305
306 let response = self
307 .client
308 .post(&url)
309 .header("Content-Type", "application/json")
310 .json(&version_payload)
311 .send()
312 .await?;
313
314 if response.status().is_success() {
315 println!(" ✅ Published {}@{}", artifact_id, publish.version);
316 Ok(())
317 } else {
318 let status = response.status();
319 let body = response
320 .text()
321 .await
322 .unwrap_or_else(|_| "Unknown error".to_string());
323 anyhow::bail!(
324 "Failed to publish {}@{}: HTTP {} - {}",
325 artifact_id,
326 publish.version,
327 status,
328 body
329 );
330 }
331 } else {
332 let payload = json!({
334 "artifactId": artifact_id,
335 "artifactType": artifact_type,
336 "name": &publish.name,
337 "description": publish.description.as_deref().unwrap_or(""),
338 "labels": publish.labels,
339 "firstVersion": {
340 "version": publish.version,
341 "content": {
342 "content": content,
343 "contentType": content_type,
344 "references": references
345 },
346 "name": &publish.name,
347 "description": publish.description.as_deref().unwrap_or(""),
348 "labels": {}
349 }
350 });
351
352 let if_exists_param = match publish.if_exists {
354 IfExistsAction::Fail => "FAIL",
355 IfExistsAction::CreateVersion => "CREATE_VERSION",
356 IfExistsAction::FindOrCreateVersion => "FIND_OR_CREATE_VERSION",
357 };
358
359 let url = format!(
361 "{}/apis/registry/v3/groups/{}/artifacts?ifExists={}",
362 self.base_url.trim_end_matches('/'),
363 group_id,
364 if_exists_param
365 );
366
367 let response = self
368 .client
369 .post(&url)
370 .header("Content-Type", "application/json")
371 .json(&payload)
372 .send()
373 .await?;
374
375 if response.status().is_success() {
376 println!(" ✅ Published {}@{}", artifact_id, publish.version);
377 Ok(())
378 } else {
379 let status = response.status();
380 let body = response
381 .text()
382 .await
383 .unwrap_or_else(|_| "Unknown error".to_string());
384 anyhow::bail!(
385 "Failed to publish {}@{}: HTTP {} - {}",
386 artifact_id,
387 publish.version,
388 status,
389 body
390 );
391 }
392 }
393 }
394
395 pub async fn version_exists(
397 &self,
398 group_id: &str,
399 artifact_id: &str,
400 version: &str,
401 ) -> Result<bool> {
402 let url = format!(
403 "{}/apis/registry/v3/groups/{}/artifacts/{}/versions/{}",
404 self.base_url, group_id, artifact_id, version
405 );
406
407 match self.client.get(&url).send().await {
408 Ok(resp) => Ok(resp.status().is_success()),
409 Err(_) => Ok(false),
410 }
411 }
412
413 pub async fn get_version_content(
415 &self,
416 group_id: &str,
417 artifact_id: &str,
418 version: &str,
419 ) -> Result<String> {
420 let url = format!(
421 "{}/apis/registry/v3/groups/{}/artifacts/{}/versions/{}/content",
422 self.base_url, group_id, artifact_id, version
423 );
424 let resp = self.client.get(&url).send().await?.error_for_status()?;
425 Ok(resp.text().await?)
426 }
427}