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