1use std::collections::HashMap;
2use std::time::Duration;
3
4use anyhow::{Context, Result, bail};
5use reqwest::blocking::Client;
6use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
7
8use crate::config::{DistributorProfileConfig, GreenticConfig};
9
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum DevIntent {
13 Dev,
14 Runtime,
15}
16
17#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum DevArtifactKind {
20 Component,
21 Pack,
22}
23
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25pub struct DevResolveRequest {
26 pub coordinate: String,
27 pub intent: DevIntent,
28 pub platform: Option<String>,
29 pub features: Vec<String>,
30}
31
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum DevLicenseType {
35 Free,
36 Commercial,
37 Trial,
38}
39
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41pub struct DevLicenseInfo {
42 pub license_type: DevLicenseType,
43 pub id: Option<String>,
44 pub requires_acceptance: bool,
45 pub checkout_url: Option<String>,
46}
47
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
49pub struct DevResolveResponse {
50 pub kind: DevArtifactKind,
51 pub name: String,
52 pub version: String,
53 pub coordinate: String,
54 pub artifact_id: String,
55 pub artifact_download_path: String,
56 pub digest: Option<String>,
57 pub license: DevLicenseInfo,
58 pub metadata: serde_json::Value,
59}
60
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
62pub struct DevLicenseRequiredErrorBody {
63 pub error: String,
64 pub coordinate: String,
65 pub message: String,
66 pub checkout_url: String,
67}
68
69#[derive(Debug)]
70pub enum DevDistributorError {
71 Http(reqwest::Error),
72 LicenseRequired(DevLicenseRequiredErrorBody),
73 Status(reqwest::StatusCode, Option<String>),
74 InvalidResponse(anyhow::Error),
75}
76
77impl std::fmt::Display for DevDistributorError {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 match self {
80 DevDistributorError::Http(err) => write!(f, "http error: {err}"),
81 DevDistributorError::LicenseRequired(body) => {
82 write!(f, "{} (checkout: {})", body.message, body.checkout_url)
83 }
84 DevDistributorError::Status(code, body) => {
85 if let Some(body) = body {
86 write!(f, "unexpected status {code}: {body}")
87 } else {
88 write!(f, "unexpected status {code}")
89 }
90 }
91 DevDistributorError::InvalidResponse(err) => write!(f, "invalid response: {err}"),
92 }
93 }
94}
95
96impl std::error::Error for DevDistributorError {}
97
98impl From<reqwest::Error> for DevDistributorError {
99 fn from(value: reqwest::Error) -> Self {
100 DevDistributorError::Http(value)
101 }
102}
103
104#[derive(Debug, Clone)]
105pub struct DistributorProfile {
106 pub name: String,
107 pub url: String,
108 pub token: Option<String>,
109}
110
111impl DistributorProfile {
112 fn from_pair(name: &str, cfg: &DistributorProfileConfig) -> Result<Self> {
113 let token = resolve_token(cfg.token.clone())?;
114 let url = cfg.url.trim_end_matches('/').to_string();
115 Ok(Self {
116 name: name.to_string(),
117 url,
118 token,
119 })
120 }
121}
122
123pub fn resolve_profile(
124 config: &GreenticConfig,
125 profile_arg: Option<&str>,
126) -> Result<DistributorProfile> {
127 let env_profile = std::env::var("GREENTIC_DISTRIBUTOR_PROFILE").ok();
128 let profile_name = profile_arg.or(env_profile.as_deref()).unwrap_or("default");
129 let map: &HashMap<String, DistributorProfileConfig> = &config.distributor.profiles;
130 let Some(profile_cfg) = map.get(profile_name) else {
131 bail!(
132 "distributor profile `{profile_name}` not found; configure it in ~/.greentic/config.toml"
133 );
134 };
135 DistributorProfile::from_pair(profile_name, profile_cfg)
136}
137
138fn resolve_token(raw: Option<String>) -> Result<Option<String>> {
139 let Some(raw) = raw else {
140 return Ok(None);
141 };
142 if let Some(rest) = raw.strip_prefix("env:") {
143 let value = std::env::var(rest)
144 .with_context(|| format!("failed to resolve env var {rest} for distributor token"))?;
145 Ok(Some(value))
146 } else {
147 Ok(Some(raw))
148 }
149}
150
151#[derive(Debug, Clone)]
152pub struct DevDistributorClient {
153 base_url: String,
154 auth_token: Option<String>,
155 http: Client,
156}
157
158impl DevDistributorClient {
159 pub fn from_profile(profile: DistributorProfile) -> Result<Self> {
160 let client = Client::builder()
161 .timeout(Duration::from_secs(30))
162 .build()
163 .context("failed to build HTTP client")?;
164 Ok(Self {
165 base_url: profile.url,
166 auth_token: profile.token,
167 http: client,
168 })
169 }
170
171 pub fn resolve(
172 &self,
173 req: &DevResolveRequest,
174 ) -> Result<DevResolveResponse, DevDistributorError> {
175 let url = format!("{}/v1/resolve", self.base_url);
176 let mut builder = self.http.post(url).header(CONTENT_TYPE, "application/json");
177 if let Some(token) = &self.auth_token {
178 builder = builder.header(AUTHORIZATION, format!("Bearer {token}"));
179 }
180 let response = builder.json(req).send()?;
181 if response.status().as_u16() == 402 {
182 let body: DevLicenseRequiredErrorBody = response
183 .json()
184 .map_err(|err| DevDistributorError::InvalidResponse(err.into()))?;
185 return Err(DevDistributorError::LicenseRequired(body));
186 }
187 if !response.status().is_success() {
188 let status = response.status();
189 let body = response.text().ok();
190 return Err(DevDistributorError::Status(status, body));
191 }
192 response
193 .json::<DevResolveResponse>()
194 .map_err(|err| DevDistributorError::InvalidResponse(err.into()))
195 }
196
197 pub fn download_artifact(
198 &self,
199 download_path: &str,
200 ) -> Result<bytes::Bytes, DevDistributorError> {
201 let trimmed_base = self.base_url.trim_end_matches('/');
202 let trimmed_path = download_path.trim_start_matches('/');
203 let url = format!("{trimmed_base}/{trimmed_path}");
204 let mut builder = self.http.get(url);
205 if let Some(token) = &self.auth_token {
206 builder = builder.header(AUTHORIZATION, format!("Bearer {token}"));
207 }
208 let response = builder.send()?;
209 if !response.status().is_success() {
210 let status = response.status();
211 let body = response.text().ok();
212 return Err(DevDistributorError::Status(status, body));
213 }
214 response.bytes().map_err(DevDistributorError::Http)
215 }
216}