1use reqwest::{Client as HttpClient, Method, StatusCode};
7use secrecy::{ExposeSecret as _, SecretString};
8use serde::{Deserialize, Serialize, de::DeserializeOwned};
9use serde_json::Value;
10use std::{fmt, time::Duration};
11use thiserror::Error;
12use url::Url;
13use uuid::Uuid;
14
15#[derive(Clone, Debug, Deserialize)]
17pub struct RegistryRelease {
18 pub version: String,
19}
20
21pub async fn latest_release() -> Result<RegistryRelease, Error> {
25 latest_release_for("silicon-commit-client").await
26}
27
28pub async fn latest_cli_release() -> Result<RegistryRelease, Error> {
32 latest_release_for("silicon-commit-cli").await
33}
34
35async fn latest_release_for(crate_name: &str) -> Result<RegistryRelease, Error> {
36 let http = HttpClient::builder()
37 .redirect(reqwest::redirect::Policy::none())
38 .timeout(Duration::from_secs(5))
39 .build()
40 .map_err(transport)?;
41 let response = http
42 .get(format!("https://crates.io/api/v1/crates/{crate_name}"))
43 .header(
44 "user-agent",
45 format!("{crate_name}/{}", env!("CARGO_PKG_VERSION")),
46 )
47 .send()
48 .await
49 .map_err(transport)?;
50 if !response.status().is_success() {
51 return Err(Error::Api {
52 status: response.status(),
53 code: "registry_unavailable".into(),
54 request_id: None,
55 });
56 }
57 let body = response.bytes().await.map_err(transport)?;
58 let value: Value = serde_json::from_slice(&body).map_err(Error::Decode)?;
59 let version = value
60 .pointer("/crate/newest")
61 .and_then(Value::as_str)
62 .ok_or(Error::Invalid("registry response missing newest version"))?;
63 Ok(RegistryRelease {
64 version: version.to_owned(),
65 })
66}
67
68#[derive(Debug, Error)]
70pub enum Error {
71 #[error(
72 "invalid API URL: use an HTTP(S) origin or /api/v1/ URL without credentials, query, or fragment"
73 )]
74 InvalidUrl,
75 #[error("invalid request: {0}")]
76 Invalid(&'static str),
77 #[error("request transport failed")]
78 Transport(#[source] reqwest::Error),
79 #[error("HTTP {status}: {code} (request ID: {request_id:?})")]
80 Api {
81 status: StatusCode,
82 code: String,
83 request_id: Option<String>,
84 },
85 #[error("response exceeds the 8 MiB limit")]
86 ResponseTooLarge,
87 #[error("response did not match the expected JSON contract")]
88 Decode(#[source] serde_json::Error),
89}
90impl Error {
91 pub fn is_unauthenticated(&self) -> bool {
93 matches!(
94 self,
95 Self::Api {
96 status: StatusCode::UNAUTHORIZED,
97 ..
98 }
99 )
100 }
101}
102
103#[derive(Clone, Debug)]
105pub struct Mutation {
106 key: String,
107 version: Option<i64>,
108}
109impl Default for Mutation {
110 fn default() -> Self {
111 Self::new()
112 }
113}
114impl Mutation {
115 pub fn new() -> Self {
116 Self {
117 key: Uuid::new_v4().to_string(),
118 version: None,
119 }
120 }
121 pub fn with_key(key: impl Into<String>) -> Result<Self, Error> {
122 let key = key.into();
123 if !(8..=255).contains(&key.len()) || !key.bytes().all(|b| b.is_ascii_graphic()) {
124 return Err(Error::Invalid(
125 "idempotency key must be 8–255 visible ASCII characters",
126 ));
127 }
128 Ok(Self { key, version: None })
129 }
130 pub fn if_match(mut self, version: i64) -> Result<Self, Error> {
131 if version < 0 {
132 return Err(Error::Invalid("version cannot be negative"));
133 }
134 self.version = Some(version);
135 Ok(self)
136 }
137 pub fn key(&self) -> &str {
138 &self.key
139 }
140}
141
142#[derive(Clone, Debug)]
144pub struct Client {
145 http: HttpClient,
146 base: Url,
147 bearer: Option<SecretString>,
148 telemetry: bool,
149 source: &'static str,
150 test_key: Option<SecretString>,
151 org_id: Option<String>,
152 mutation: Option<Mutation>,
153}
154
155#[derive(Clone, Deserialize, Serialize)]
157pub struct SessionTokens {
158 pub access_token: String,
159 pub refresh_token: String,
160 pub token_type: Value,
161 pub expires_in: i64,
162 pub scope: String,
163 pub actor: Value,
164 pub org_id: Option<String>,
165}
166impl fmt::Debug for SessionTokens {
167 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168 f.debug_struct("SessionTokens")
169 .field("tokens", &"<redacted>")
170 .field("org_id", &self.org_id)
171 .finish()
172 }
173}
174
175impl Client {
176 pub fn new(base_url: &str) -> Result<Self, Error> {
178 let mut base = Url::parse(base_url).map_err(|_| Error::InvalidUrl)?;
179 if !matches!(base.scheme(), "http" | "https")
180 || base.host_str().is_none()
181 || !base.username().is_empty()
182 || base.password().is_some()
183 || base.query().is_some()
184 || base.fragment().is_some()
185 || !matches!(base.path(), "" | "/" | "/api/v1" | "/api/v1/")
186 {
187 return Err(Error::InvalidUrl);
188 }
189 base.set_path("/api/v1/");
190 let http = HttpClient::builder()
191 .redirect(reqwest::redirect::Policy::none())
192 .connect_timeout(Duration::from_secs(5))
193 .timeout(Duration::from_secs(30))
194 .user_agent(concat!("silicon-commit-client/", env!("CARGO_PKG_VERSION")))
195 .build()
196 .map_err(transport)?;
197 Ok(Self {
198 http,
199 base,
200 bearer: None,
201 telemetry: true,
202 source: "rust-client",
203 test_key: None,
204 org_id: None,
205 mutation: None,
206 })
207 }
208 pub fn with_bearer(mut self, token: impl Into<String>) -> Self {
209 self.bearer = Some(SecretString::from(token.into()));
210 self
211 }
212 pub fn with_test_key(mut self, key: impl Into<String>) -> Result<Self, Error> {
213 let key = key.into();
214 if !valid_testing_selector(&key) {
215 return Err(Error::Invalid(
216 "test selector must be an IAM test app_secret or a legacy 32-character Commit key",
217 ));
218 }
219 self.test_key = Some(SecretString::from(key));
220 Ok(self)
221 }
222 pub fn with_test_app_secret(self, secret: impl Into<String>) -> Result<Self, Error> {
224 self.with_test_key(secret)
225 }
226 pub async fn contracts(&self) -> Result<Value, Error> {
228 self.get(&["contracts"], &[]).await
229 }
230 pub fn with_telemetry(mut self, enabled: bool) -> Self {
232 self.telemetry = enabled;
233 self
234 }
235 pub fn with_source(mut self, source: &'static str) -> Result<Self, Error> {
237 if !matches!(source, "cli" | "browser" | "daemon" | "rust-client") {
238 return Err(Error::Invalid("unknown client source"));
239 }
240 self.source = source;
241 Ok(self)
242 }
243 pub async fn email_settings(&self) -> Result<Value, Error> {
245 self.get(&["email-settings"], &[]).await
246 }
247 pub async fn set_email_settings(&self, body: &Value) -> Result<Value, Error> {
249 self.write(Method::PUT, &["email-settings"], body).await
250 }
251 pub async fn report(&self, body: &Value) -> Result<Value, Error> {
253 self.write(Method::POST, &["reports"], body).await
254 }
255 pub async fn testing_context(&self) -> Result<Value, Error> {
256 self.get(&["testing-context"], &[]).await
257 }
258 pub async fn claim_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
260 self.write(
261 Method::POST,
262 &["projects", project, "tasks", task, "claim"],
263 &serde_json::json!({}),
264 )
265 .await
266 }
267 pub async fn delete_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
269 self.execute(
270 Method::DELETE,
271 &["projects", project, "tasks", task],
272 None,
273 &[],
274 false,
275 )
276 .await
277 }
278 pub async fn project_versions(
280 &self,
281 project: &str,
282 query: &[(&str, &str)],
283 ) -> Result<Value, Error> {
284 self.get(&["projects", project, "versions"], query).await
285 }
286 pub async fn project_version(&self, project: &str, version: i64) -> Result<Value, Error> {
288 self.get(
289 &["projects", project, "versions", &version.to_string()],
290 &[],
291 )
292 .await
293 }
294 pub fn with_org_id(mut self, org_id: impl Into<String>) -> Self {
295 self.org_id = Some(org_id.into());
296 self
297 }
298 pub fn with_mutation(mut self, mutation: Mutation) -> Self {
300 self.mutation = Some(mutation);
301 self
302 }
303 pub fn base_url(&self) -> &Url {
304 &self.base
305 }
306 pub fn new_idempotency_key() -> String {
307 Mutation::new().key
308 }
309 pub async fn health(&self) -> Result<Value, Error> {
310 self.execute(Method::GET, &["healthz"], None, &[], true)
311 .await
312 }
313 pub async fn ready(&self) -> Result<Value, Error> {
314 self.execute(Method::GET, &["readyz"], None, &[], true)
315 .await
316 }
317 pub async fn version(&self) -> Result<Value, Error> {
318 let mut public = self.clone();
320 public.bearer = None;
321 public.org_id = None;
322 public.test_key = None;
323 public.get(&["version"], &[]).await
324 }
325 pub async fn iam(&self) -> Result<Value, Error> {
327 let mut public = self.clone();
328 public.bearer = None;
329 public.org_id = None;
330 public.test_key = None;
331 public.get(&["iam"], &[]).await
332 }
333 pub async fn login_status(&self) -> Result<Value, Error> {
337 if self
338 .bearer
339 .as_ref()
340 .is_none_or(|token| token.expose_secret().is_empty())
341 {
342 return Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}));
343 }
344 match self.get(&["auth", "status"], &[]).await {
345 Err(error) if error.is_unauthenticated() => {
346 Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}))
347 }
348 result => result,
349 }
350 }
351 pub async fn login_with_slt(&self, slt: &str) -> Result<SessionTokens, Error> {
352 decode(
353 self.write(
354 Method::POST,
355 &["auth", "login"],
356 &serde_json::json!({"slt":slt}),
357 )
358 .await?,
359 )
360 }
361 pub async fn refresh_session(&self, refresh_token: &str) -> Result<SessionTokens, Error> {
362 decode(
363 self.write(
364 Method::POST,
365 &["auth", "refresh"],
366 &serde_json::json!({"refresh_token":refresh_token}),
367 )
368 .await?,
369 )
370 }
371 pub async fn logout(&self, token: &str) -> Result<(), Error> {
372 self.write(
373 Method::POST,
374 &["auth", "logout"],
375 &serde_json::json!({"token":token}),
376 )
377 .await?;
378 Ok(())
379 }
380 pub async fn list_todos(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
382 self.get(&["todos"], query).await
383 }
384 pub async fn get_todo(&self, todo_id: &str) -> Result<Value, Error> {
386 self.get(&["todos", todo_id], &[]).await
387 }
388 pub async fn create_todo<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
390 self.write(Method::POST, &["todos"], body).await
391 }
392 pub async fn update_todo<T: Serialize + ?Sized>(
394 &self,
395 todo_id: &str,
396 body: &T,
397 ) -> Result<Value, Error> {
398 self.write(Method::PATCH, &["todos", todo_id], body).await
399 }
400 pub async fn delete_todo(&self, todo_id: &str) -> Result<Value, Error> {
402 self.execute(Method::DELETE, &["todos", todo_id], None, &[], false)
403 .await
404 }
405 pub async fn list_notes(&self, todo_id: &str, query: &[(&str, &str)]) -> Result<Value, Error> {
407 self.get(&["todos", todo_id, "notes"], query).await
408 }
409 pub async fn add_note<T: Serialize + ?Sized>(
411 &self,
412 todo_id: &str,
413 body: &T,
414 ) -> Result<Value, Error> {
415 self.write(Method::POST, &["todos", todo_id, "notes"], body)
416 .await
417 }
418 pub async fn todo_subscription(&self, todo_id: &str) -> Result<Value, Error> {
420 self.get(&["todos", todo_id, "notification-subscription"], &[])
421 .await
422 }
423 pub async fn replace_todo_subscription<T: Serialize + ?Sized>(
425 &self,
426 todo_id: &str,
427 body: &T,
428 ) -> Result<Value, Error> {
429 self.write(
430 Method::PUT,
431 &["todos", todo_id, "notification-subscription"],
432 body,
433 )
434 .await
435 }
436 pub async fn notification_settings(&self) -> Result<Value, Error> {
438 self.get(&["notification-settings"], &[]).await
439 }
440 pub async fn update_notification_settings<T: Serialize + ?Sized>(
442 &self,
443 body: &T,
444 ) -> Result<Value, Error> {
445 self.write(Method::PUT, &["notification-settings"], body)
446 .await
447 }
448 pub async fn list_projects(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
450 self.get(&["projects"], query).await
451 }
452 pub async fn get_project(&self, project_id: &str) -> Result<Value, Error> {
454 self.get(&["projects", project_id], &[]).await
455 }
456 pub async fn create_project<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
458 self.write(Method::POST, &["projects"], body).await
459 }
460 pub async fn update_project<T: Serialize + ?Sized>(
462 &self,
463 project_id: &str,
464 body: &T,
465 ) -> Result<Value, Error> {
466 self.write(Method::PATCH, &["projects", project_id], body)
467 .await
468 }
469 pub async fn project_diary(&self, project_id: &str) -> Result<Value, Error> {
471 self.get(&["projects", project_id, "diary"], &[]).await
472 }
473 pub async fn replace_project_diary<T: Serialize + ?Sized>(
475 &self,
476 project_id: &str,
477 body: &T,
478 ) -> Result<Value, Error> {
479 self.write(Method::PUT, &["projects", project_id, "diary"], body)
480 .await
481 }
482 pub async fn project_tasks(
484 &self,
485 project_id: &str,
486 query: &[(&str, &str)],
487 ) -> Result<Value, Error> {
488 self.get(&["projects", project_id, "tasks"], query).await
489 }
490 pub async fn project_entries(
492 &self,
493 project_id: &str,
494 query: &[(&str, &str)],
495 ) -> Result<Value, Error> {
496 self.get(&["projects", project_id, "entries"], query).await
497 }
498 pub async fn create_project_task<T: Serialize + ?Sized>(
500 &self,
501 project_id: &str,
502 body: &T,
503 ) -> Result<Value, Error> {
504 self.write(Method::POST, &["projects", project_id, "tasks"], body)
505 .await
506 }
507 pub async fn update_project_task<T: Serialize + ?Sized>(
509 &self,
510 project_id: &str,
511 task_id: &str,
512 body: &T,
513 ) -> Result<Value, Error> {
514 self.write(
515 Method::PATCH,
516 &["projects", project_id, "tasks", task_id],
517 body,
518 )
519 .await
520 }
521 pub async fn create_project_blocker<T: Serialize + ?Sized>(
523 &self,
524 project_id: &str,
525 body: &T,
526 ) -> Result<Value, Error> {
527 self.write(Method::POST, &["projects", project_id, "blockers"], body)
528 .await
529 }
530 pub async fn create_project_update<T: Serialize + ?Sized>(
532 &self,
533 project_id: &str,
534 body: &T,
535 ) -> Result<Value, Error> {
536 self.write(Method::POST, &["projects", project_id, "updates"], body)
537 .await
538 }
539 pub async fn complete_project<T: Serialize + ?Sized>(
541 &self,
542 project_id: &str,
543 body: &T,
544 ) -> Result<Value, Error> {
545 self.write(Method::POST, &["projects", project_id, "completion"], body)
546 .await
547 }
548 pub async fn list_test_environments(&self) -> Result<Value, Error> {
550 self.get(&["test-environments"], &[]).await
551 }
552 pub async fn create_test_environment<T: Serialize + ?Sized>(
555 &self,
556 body: &T,
557 ) -> Result<Value, Error> {
558 self.write(Method::POST, &["test-environments"], body).await
559 }
560 pub async fn retrieve_test_environment_key(&self, id: &str) -> Result<Value, Error> {
562 self.get(&["test-environments", id, "key"], &[]).await
563 }
564 pub async fn rotate_test_environment(&self, id: &str) -> Result<Value, Error> {
566 self.execute(
567 Method::POST,
568 &["test-environments", id, "rotate"],
569 None,
570 &[],
571 false,
572 )
573 .await
574 }
575 pub async fn delete_test_environment(&self, id: &str) -> Result<Value, Error> {
577 self.execute(Method::DELETE, &["test-environments", id], None, &[], false)
578 .await
579 }
580 pub async fn restore_test_environment(&self, id: &str) -> Result<Value, Error> {
581 self.execute(
582 Method::POST,
583 &["test-environments", id, "restore"],
584 None,
585 &[],
586 false,
587 )
588 .await
589 }
590 pub async fn clean_test_environment(&self, id: &str) -> Result<Value, Error> {
591 self.execute(
592 Method::POST,
593 &["test-environments", id, "clean"],
594 None,
595 &[],
596 false,
597 )
598 .await
599 }
600 async fn get(&self, path: &[&str], query: &[(&str, &str)]) -> Result<Value, Error> {
601 self.execute(Method::GET, path, None, query, false).await
602 }
603 async fn write<T: Serialize + ?Sized>(
604 &self,
605 method: Method,
606 path: &[&str],
607 body: &T,
608 ) -> Result<Value, Error> {
609 let body = serde_json::to_vec(body).map_err(Error::Decode)?;
610 self.execute(method, path, Some(body), &[], false).await
611 }
612 fn url(&self, path: &[&str], root: bool) -> Result<Url, Error> {
613 if path
614 .iter()
615 .any(|p| p.is_empty() || matches!(*p, "." | ".."))
616 {
617 return Err(Error::Invalid(
618 "empty and dot resource identifiers are forbidden",
619 ));
620 }
621 let mut url = self.base.clone();
622 {
623 let mut segments = url.path_segments_mut().map_err(|_| Error::InvalidUrl)?;
624 if root {
625 segments.clear();
626 } else {
627 segments.pop_if_empty();
628 }
629 segments.extend(path);
630 }
631 Ok(url)
632 }
633 async fn execute(
634 &self,
635 method: Method,
636 path: &[&str],
637 body: Option<Vec<u8>>,
638 query: &[(&str, &str)],
639 root: bool,
640 ) -> Result<Value, Error> {
641 let is_mutation = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
642 let mut req = self
643 .http
644 .request(method, self.url(path, root)?)
645 .query(query)
646 .header("x-commit-supported-versions", "1")
647 .header("x-commit-client", self.source)
648 .header(
649 "x-commit-telemetry",
650 if self.telemetry { "on" } else { "off" },
651 );
652 if !root {
653 if let Some(token) = &self.bearer {
654 req = req.bearer_auth(token.expose_secret());
655 }
656 if let Some(org) = &self.org_id {
657 req = req.header("x-org-id", org);
658 }
659 if let Some(key) = &self.test_key {
660 let key = key.expose_secret();
661 if !valid_testing_selector(key) {
662 return Err(Error::Invalid("invalid testing application secret"));
663 }
664 req = req.header("x-testing-environment-key", key);
665 }
666 }
667 if is_mutation {
668 let mutation = self.mutation.clone().unwrap_or_default();
669 req = req.header("idempotency-key", mutation.key);
670 if let Some(version) = mutation.version {
671 req = req.header("if-match", format!("\"{version}\""));
672 }
673 }
674 if let Some(body) = body {
675 req = req.header("content-type", "application/json").body(body);
676 }
677 let mut response = req.send().await.map_err(transport)?;
678 if response
679 .headers()
680 .get("x-commit-api-version")
681 .is_some_and(|v| v != "1")
682 {
683 return Err(Error::Invalid(
684 "server selected an unsupported API contract",
685 ));
686 }
687 let status = response.status();
688 let request_id = response
689 .headers()
690 .get("x-request-id")
691 .and_then(|v| v.to_str().ok())
692 .map(str::to_owned);
693 const LIMIT: usize = 8 * 1024 * 1024;
694 if response.content_length().is_some_and(|n| n > LIMIT as u64) {
695 return Err(Error::ResponseTooLarge);
696 }
697 let mut bytes = Vec::new();
698 while let Some(chunk) = response.chunk().await.map_err(transport)? {
699 if bytes.len() + chunk.len() > LIMIT {
700 return Err(Error::ResponseTooLarge);
701 }
702 bytes.extend_from_slice(&chunk);
703 }
704 if !status.is_success() {
705 let parsed: Option<Value> = serde_json::from_slice(&bytes).ok();
706 let code = parsed
707 .as_ref()
708 .and_then(|v| v.pointer("/error/code"))
709 .and_then(Value::as_str)
710 .filter(|s| {
711 s.len() <= 128 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
712 })
713 .unwrap_or("http_error")
714 .to_owned();
715 return Err(Error::Api {
716 status,
717 code,
718 request_id,
719 });
720 }
721 if bytes.is_empty() {
722 return Ok(Value::Null);
723 }
724 serde_json::from_slice(&bytes).map_err(Error::Decode)
725 }
726}
727fn transport(error: reqwest::Error) -> Error {
728 Error::Transport(error.without_url())
729}
730fn decode<T: DeserializeOwned>(value: Value) -> Result<T, Error> {
731 serde_json::from_value(value).map_err(Error::Decode)
732}
733
734fn valid_testing_selector(key: &str) -> bool {
735 (key.len() == 32 && key.bytes().all(|b| b.is_ascii_alphanumeric()))
736 || (key.len() == 47
737 && key.starts_with("ask_")
738 && key[4..]
739 .bytes()
740 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'))
741}