1#![doc = include_str!("../README.md")]
8#![forbid(unsafe_code)]
9
10use std::fmt;
11use std::fmt::Write as _;
12use std::sync::Arc;
13use std::time::Duration;
14
15use bytes::Bytes;
16use cratefield_core::{Clock, HttpClient, HttpError, retry_after};
17use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
18use http::{Method, Request, StatusCode};
19use serde::{Deserialize, Serialize, de::DeserializeOwned};
20
21const API: &str = "/api/v1";
23
24#[non_exhaustive]
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ColonizerError {
28 NotConfigured,
30 Invalid { detail: String },
33 Unauthorized,
35 Forbidden { scope: Option<String> },
37 NotFound,
39 Conflict { detail: String },
41 Transient { retry_after: Option<Duration> },
43 Decode { detail: String },
45 Transport { detail: String },
48}
49
50impl fmt::Display for ColonizerError {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 match self {
53 Self::NotConfigured => f.write_str("colonizer is not configured: no API token"),
54 Self::Invalid { detail } => write!(f, "colonizer rejected the request: {detail}"),
55 Self::Unauthorized => f.write_str("colonizer rejected the token (401)"),
56 Self::Forbidden { scope: Some(s) } => {
57 write!(f, "the token lacks the {s:?} scope (403)")
58 }
59 Self::Forbidden { scope: None } => f.write_str("the token may not do that (403)"),
60 Self::NotFound => f.write_str("no such colony (404)"),
61 Self::Conflict { detail } => write!(f, "the colony is in the wrong state: {detail}"),
62 Self::Transient {
63 retry_after: Some(d),
64 } => {
65 write!(f, "colonizer is unavailable, retry in {}s", d.as_secs())
66 }
67 Self::Transient { retry_after: None } => f.write_str("colonizer is unavailable"),
68 Self::Decode { detail } => write!(f, "could not read the reply: {detail}"),
69 Self::Transport { detail } => write!(f, "could not reach the mothership: {detail}"),
70 }
71 }
72}
73
74impl std::error::Error for ColonizerError {}
75
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Whoami {
79 pub login: String,
80 #[serde(default)]
81 pub scopes: Vec<String>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87pub struct NewColony {
88 pub repo: String,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub issue: Option<u64>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub prompt: Option<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct Colony {
99 pub id: String,
100 pub status: String,
101 #[serde(default)]
102 pub repo: String,
103 #[serde(default)]
104 pub issue: Option<u64>,
105 #[serde(default)]
106 pub branch: Option<String>,
107}
108
109#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
112pub struct ColonyPage {
113 #[serde(default)]
114 pub colonies: Vec<Colony>,
115 #[serde(default)]
116 pub next_cursor: Option<String>,
117}
118
119#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
122pub struct Question {
123 pub id: String,
124 pub text: String,
125 #[serde(default)]
126 pub options: Vec<String>,
127}
128
129pub struct Colonizer {
132 inner: Option<Live>,
133}
134
135struct Live {
136 http: Arc<dyn HttpClient>,
137 clock: Arc<dyn Clock>,
139 token: String,
140 base_url: String,
141}
142
143impl fmt::Debug for Colonizer {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 match &self.inner {
146 Some(live) => f
147 .debug_struct("Colonizer")
148 .field("base_url", &live.base_url)
149 .field("token_configured", &!live.token.is_empty())
150 .finish_non_exhaustive(),
151 None => f.write_str("Colonizer(NotConfigured)"),
152 }
153 }
154}
155
156impl Colonizer {
157 #[must_use]
160 pub fn new(
161 http: Arc<dyn HttpClient>,
162 clock: Arc<dyn Clock>,
163 base_url: impl Into<String>,
164 token: Option<String>,
165 ) -> Self {
166 let (base_url, token) = (base_url.into(), token.unwrap_or_default());
167 let usable = !base_url.trim().is_empty() && !token.trim().is_empty();
168 let inner = usable.then(|| Live {
169 http,
170 clock,
171 token: token.trim().to_owned(),
172 base_url: base_url.trim().to_owned(),
173 });
174 Self { inner }
175 }
176
177 pub async fn whoami(&self) -> Result<Whoami, ColonizerError> {
182 self.json(Method::GET, &format!("{API}/whoami"), None::<&()>)
183 .await
184 }
185
186 pub async fn create_session(&self, new: &NewColony) -> Result<Colony, ColonizerError> {
191 self.json(Method::POST, &format!("{API}/colonies"), Some(new))
192 .await
193 }
194
195 pub async fn get(&self, id: &str) -> Result<Colony, ColonizerError> {
200 self.colony(id, "get").await
201 }
202
203 pub async fn list(
209 &self,
210 limit: u32,
211 cursor: Option<&str>,
212 ) -> Result<ColonyPage, ColonizerError> {
213 let mut path = format!("{API}/colonies?limit={limit}");
214 if let Some(cursor) = cursor {
215 let _ = write!(path, "&cursor={}", percent_encode(cursor));
216 }
217 self.json(Method::GET, &path, None::<&()>).await
218 }
219
220 pub async fn question(&self, id: &str) -> Result<Option<Question>, ColonizerError> {
226 let path = format!("{}/question", Self::colony_path(id)?);
227 self.json(Method::GET, &path, None::<&()>).await
228 }
229
230 pub async fn answer(
235 &self,
236 id: &str,
237 question_id: &str,
238 answer: &str,
239 ) -> Result<(), ColonizerError> {
240 self.post(
241 id,
242 "answer",
243 &serde_json::json!({
244 "question_id": question_id,
245 "answer": answer,
246 }),
247 )
248 .await
249 }
250
251 pub async fn message(&self, id: &str, text: &str) -> Result<(), ColonizerError> {
256 self.post(id, "messages", &serde_json::json!({ "text": text }))
257 .await
258 }
259
260 pub async fn stop(&self, id: &str) -> Result<Colony, ColonizerError> {
266 self.colony(id, "stop").await
267 }
268
269 pub async fn resume(&self, id: &str) -> Result<Colony, ColonizerError> {
275 self.colony(id, "resume").await
276 }
277
278 async fn colony(&self, id: &str, action: &str) -> Result<Colony, ColonizerError> {
281 let path = Self::colony_path(id)?;
282 let (method, path) = if action == "get" {
283 (Method::GET, path)
284 } else {
285 (Method::POST, format!("{path}/{action}"))
286 };
287 self.json(method, &path, None::<&()>).await
288 }
289
290 async fn post(
292 &self,
293 id: &str,
294 action: &str,
295 body: &serde_json::Value,
296 ) -> Result<(), ColonizerError> {
297 let path = format!("{}/{action}", Self::colony_path(id)?);
298 let _: serde_json::Value = self.json(Method::POST, &path, Some(body)).await?;
299 Ok(())
300 }
301
302 fn colony_path(id: &str) -> Result<String, ColonizerError> {
306 let refused = id.is_empty()
307 || id.contains("..")
308 || !id
309 .chars()
310 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'));
311 if refused {
312 let detail = format!("{id:?} is not a colony id");
313 return Err(ColonizerError::Invalid {
314 detail: cratefield_core::scrub_text(&detail),
315 });
316 }
317 Ok(format!("{API}/colonies/{id}"))
318 }
319
320 async fn json<T: DeserializeOwned, B: Serialize>(
323 &self,
324 method: Method,
325 path: &str,
326 body: Option<&B>,
327 ) -> Result<T, ColonizerError> {
328 let live = self.live()?;
329 let body = body
330 .map(serde_json::to_vec)
331 .transpose()
332 .map_err(|err| live.transport(&err.to_string()))?
333 .map(Bytes::from);
334 let text = live.send(&method, path, body).await?;
335 match serde_json::from_str(&text) {
336 Ok(value) => Ok(value),
337 Err(_) if text.trim().is_empty() || text.trim() == "null" => {
340 serde_json::from_str("null").map_err(|e| live.decode(&e.to_string()))
341 }
342 Err(err) => Err(live.decode(&err.to_string())),
343 }
344 }
345
346 fn live(&self) -> Result<&Live, ColonizerError> {
347 self.inner.as_ref().ok_or_else(|| {
348 tracing::info!(
349 provider = "colonizer",
350 outcome = "not_configured",
351 "colony outcome"
352 );
353 ColonizerError::NotConfigured
354 })
355 }
356}
357
358impl Live {
359 async fn send(
362 &self,
363 method: &Method,
364 path: &str,
365 body: Option<Bytes>,
366 ) -> Result<String, ColonizerError> {
367 let mut builder = Request::builder()
368 .method(method.clone())
369 .uri(format!("{}{path}", self.base_url.trim_end_matches('/')))
370 .header(AUTHORIZATION, format!("Bearer {}", self.token))
371 .header(ACCEPT, "application/json");
372 if body.is_some() {
373 builder = builder.header(CONTENT_TYPE, "application/json");
374 }
375 let request = builder
376 .body(body.unwrap_or_default())
377 .map_err(|err| self.transport(&err.to_string()))?;
378
379 let response = self
380 .http
381 .send(request)
382 .await
383 .map_err(|err: HttpError| self.transport(&err.to_string()))?;
384 let status = response.status();
385 let retry_after = retry_after(response.headers(), self.clock.as_ref());
386 let text = String::from_utf8_lossy(response.body()).to_string();
387 if status.is_success() {
388 tracing::info!(provider = "colonizer", code = status.as_u16(), method = %method, outcome = "ok", "colony outcome");
389 return Ok(text);
390 }
391 let error = self.map_status(status, &text, retry_after);
392 tracing::warn!(provider = "colonizer", code = status.as_u16(), method = %method, outcome = "failed", error = %error, "colony outcome");
393 Err(error)
394 }
395
396 fn map_status(
398 &self,
399 status: StatusCode,
400 body: &str,
401 retry_after: Option<Duration>,
402 ) -> ColonizerError {
403 let detail = || self.provider_detail(body);
404 match status {
405 StatusCode::UNAUTHORIZED => ColonizerError::Unauthorized,
406 StatusCode::FORBIDDEN => ColonizerError::Forbidden {
407 scope: self.scope(body),
408 },
409 StatusCode::NOT_FOUND => ColonizerError::NotFound,
410 StatusCode::CONFLICT => ColonizerError::Conflict { detail: detail() },
411 other if other == StatusCode::TOO_MANY_REQUESTS || other.is_server_error() => {
413 ColonizerError::Transient { retry_after }
414 }
415 _ => ColonizerError::Invalid { detail: detail() },
417 }
418 }
419
420 fn provider_detail(&self, body: &str) -> String {
423 let text = Self::field(body, "error").unwrap_or_else(|| body.trim().to_owned());
424 redact(&text, &self.token)
425 }
426
427 fn scope(&self, body: &str) -> Option<String> {
430 Self::field(body, "scope")
431 .or_else(|| Self::field(body, "required_scope"))
432 .map(|scope| redact(&scope, &self.token))
433 }
434
435 fn field(body: &str, name: &str) -> Option<String> {
436 serde_json::from_str::<serde_json::Value>(body)
437 .ok()?
438 .get(name)?
439 .as_str()
440 .map(str::to_owned)
441 }
442
443 fn transport(&self, err: &str) -> ColonizerError {
445 ColonizerError::Transport {
446 detail: redact(err, &self.token),
447 }
448 }
449
450 fn decode(&self, err: &str) -> ColonizerError {
452 ColonizerError::Decode {
453 detail: redact(err, &self.token),
454 }
455 }
456}
457
458fn redact(text: &str, token: &str) -> String {
461 cratefield_core::scrub_text(&text.replace(token, "[redacted]"))
462}
463
464fn percent_encode(value: &str) -> String {
466 let mut out = String::with_capacity(value.len());
467 for byte in value.bytes() {
468 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
469 out.push(char::from(byte));
470 } else {
471 let _ = write!(out, "%{byte:02X}");
472 }
473 }
474 out
475}