use reqwest::{Client as HttpClient, Method, StatusCode};
use secrecy::{ExposeSecret as _, SecretString};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::Value;
use std::{fmt, time::Duration};
use thiserror::Error;
use url::Url;
use uuid::Uuid;
#[derive(Clone, Debug, Deserialize)]
pub struct RegistryRelease {
pub version: String,
}
pub async fn latest_release() -> Result<RegistryRelease, Error> {
latest_release_for("silicon-commit-client").await
}
pub async fn latest_cli_release() -> Result<RegistryRelease, Error> {
latest_release_for("silicon-commit-cli").await
}
async fn latest_release_for(crate_name: &str) -> Result<RegistryRelease, Error> {
let http = HttpClient::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(5))
.build()
.map_err(transport)?;
let response = http
.get(format!("https://crates.io/api/v1/crates/{crate_name}"))
.header(
"user-agent",
format!("{crate_name}/{}", env!("CARGO_PKG_VERSION")),
)
.send()
.await
.map_err(transport)?;
if !response.status().is_success() {
return Err(Error::Api {
status: response.status(),
code: "registry_unavailable".into(),
request_id: None,
});
}
let body = response.bytes().await.map_err(transport)?;
let value: Value = serde_json::from_slice(&body).map_err(Error::Decode)?;
let version = value
.pointer("/crate/newest")
.and_then(Value::as_str)
.ok_or(Error::Invalid("registry response missing newest version"))?;
Ok(RegistryRelease {
version: version.to_owned(),
})
}
#[derive(Debug, Error)]
pub enum Error {
#[error(
"invalid API URL: use an HTTP(S) origin or /api/v1/ URL without credentials, query, or fragment"
)]
InvalidUrl,
#[error("invalid request: {0}")]
Invalid(&'static str),
#[error("request transport failed")]
Transport(#[source] reqwest::Error),
#[error("HTTP {status}: {code} (request ID: {request_id:?})")]
Api {
status: StatusCode,
code: String,
request_id: Option<String>,
},
#[error("response exceeds the 8 MiB limit")]
ResponseTooLarge,
#[error("response did not match the expected JSON contract")]
Decode(#[source] serde_json::Error),
}
impl Error {
pub fn is_unauthenticated(&self) -> bool {
matches!(
self,
Self::Api {
status: StatusCode::UNAUTHORIZED,
..
}
)
}
}
#[derive(Clone, Debug)]
pub struct Mutation {
key: String,
version: Option<i64>,
}
impl Default for Mutation {
fn default() -> Self {
Self::new()
}
}
impl Mutation {
pub fn new() -> Self {
Self {
key: Uuid::new_v4().to_string(),
version: None,
}
}
pub fn with_key(key: impl Into<String>) -> Result<Self, Error> {
let key = key.into();
if !(8..=255).contains(&key.len()) || !key.bytes().all(|b| b.is_ascii_graphic()) {
return Err(Error::Invalid(
"idempotency key must be 8–255 visible ASCII characters",
));
}
Ok(Self { key, version: None })
}
pub fn if_match(mut self, version: i64) -> Result<Self, Error> {
if version < 0 {
return Err(Error::Invalid("version cannot be negative"));
}
self.version = Some(version);
Ok(self)
}
pub fn key(&self) -> &str {
&self.key
}
}
#[derive(Clone, Debug)]
pub struct Client {
http: HttpClient,
base: Url,
bearer: Option<SecretString>,
telemetry: bool,
source: &'static str,
test_key: Option<SecretString>,
org_id: Option<String>,
mutation: Option<Mutation>,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct SessionTokens {
pub access_token: String,
pub refresh_token: String,
pub token_type: Value,
pub expires_in: i64,
pub scope: String,
pub actor: Value,
pub org_id: Option<String>,
}
impl fmt::Debug for SessionTokens {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SessionTokens")
.field("tokens", &"<redacted>")
.field("org_id", &self.org_id)
.finish()
}
}
impl Client {
pub fn new(base_url: &str) -> Result<Self, Error> {
let mut base = Url::parse(base_url).map_err(|_| Error::InvalidUrl)?;
if !matches!(base.scheme(), "http" | "https")
|| base.host_str().is_none()
|| !base.username().is_empty()
|| base.password().is_some()
|| base.query().is_some()
|| base.fragment().is_some()
|| !matches!(base.path(), "" | "/" | "/api/v1" | "/api/v1/")
{
return Err(Error::InvalidUrl);
}
base.set_path("/api/v1/");
let http = HttpClient::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.user_agent(concat!("silicon-commit-client/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(transport)?;
Ok(Self {
http,
base,
bearer: None,
telemetry: true,
source: "rust-client",
test_key: None,
org_id: None,
mutation: None,
})
}
pub fn with_bearer(mut self, token: impl Into<String>) -> Self {
self.bearer = Some(SecretString::from(token.into()));
self
}
pub fn with_test_key(mut self, key: impl Into<String>) -> Result<Self, Error> {
let key = key.into();
if !valid_testing_selector(&key) {
return Err(Error::Invalid(
"test selector must be an IAM test app_secret or a legacy 32-character Commit key",
));
}
self.test_key = Some(SecretString::from(key));
Ok(self)
}
pub fn with_test_app_secret(self, secret: impl Into<String>) -> Result<Self, Error> {
self.with_test_key(secret)
}
pub async fn contracts(&self) -> Result<Value, Error> {
self.get(&["contracts"], &[]).await
}
pub fn with_telemetry(mut self, enabled: bool) -> Self {
self.telemetry = enabled;
self
}
pub fn with_source(mut self, source: &'static str) -> Result<Self, Error> {
if !matches!(source, "cli" | "browser" | "daemon" | "rust-client") {
return Err(Error::Invalid("unknown client source"));
}
self.source = source;
Ok(self)
}
pub async fn email_settings(&self) -> Result<Value, Error> {
self.get(&["email-settings"], &[]).await
}
pub async fn set_email_settings(&self, body: &Value) -> Result<Value, Error> {
self.write(Method::PUT, &["email-settings"], body).await
}
pub async fn report(&self, body: &Value) -> Result<Value, Error> {
self.write(Method::POST, &["reports"], body).await
}
pub async fn testing_context(&self) -> Result<Value, Error> {
self.get(&["testing-context"], &[]).await
}
pub async fn claim_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
self.write(
Method::POST,
&["projects", project, "tasks", task, "claim"],
&serde_json::json!({}),
)
.await
}
pub async fn delete_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
self.execute(
Method::DELETE,
&["projects", project, "tasks", task],
None,
&[],
false,
)
.await
}
pub async fn project_versions(
&self,
project: &str,
query: &[(&str, &str)],
) -> Result<Value, Error> {
self.get(&["projects", project, "versions"], query).await
}
pub async fn project_version(&self, project: &str, version: i64) -> Result<Value, Error> {
self.get(
&["projects", project, "versions", &version.to_string()],
&[],
)
.await
}
pub fn with_org_id(mut self, org_id: impl Into<String>) -> Self {
self.org_id = Some(org_id.into());
self
}
pub fn with_mutation(mut self, mutation: Mutation) -> Self {
self.mutation = Some(mutation);
self
}
pub fn base_url(&self) -> &Url {
&self.base
}
pub fn new_idempotency_key() -> String {
Mutation::new().key
}
pub async fn health(&self) -> Result<Value, Error> {
self.execute(Method::GET, &["healthz"], None, &[], true)
.await
}
pub async fn ready(&self) -> Result<Value, Error> {
self.execute(Method::GET, &["readyz"], None, &[], true)
.await
}
pub async fn version(&self) -> Result<Value, Error> {
let mut public = self.clone();
public.bearer = None;
public.org_id = None;
public.test_key = None;
public.get(&["version"], &[]).await
}
pub async fn iam(&self) -> Result<Value, Error> {
let mut public = self.clone();
public.bearer = None;
public.org_id = None;
public.test_key = None;
public.get(&["iam"], &[]).await
}
pub async fn login_status(&self) -> Result<Value, Error> {
if self
.bearer
.as_ref()
.is_none_or(|token| token.expose_secret().is_empty())
{
return Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}));
}
match self.get(&["auth", "status"], &[]).await {
Err(error) if error.is_unauthenticated() => {
Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}))
}
result => result,
}
}
pub async fn login_with_slt(&self, slt: &str) -> Result<SessionTokens, Error> {
decode(
self.write(
Method::POST,
&["auth", "login"],
&serde_json::json!({"slt":slt}),
)
.await?,
)
}
pub async fn refresh_session(&self, refresh_token: &str) -> Result<SessionTokens, Error> {
decode(
self.write(
Method::POST,
&["auth", "refresh"],
&serde_json::json!({"refresh_token":refresh_token}),
)
.await?,
)
}
pub async fn logout(&self, token: &str) -> Result<(), Error> {
self.write(
Method::POST,
&["auth", "logout"],
&serde_json::json!({"token":token}),
)
.await?;
Ok(())
}
pub async fn list_todos(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
self.get(&["todos"], query).await
}
pub async fn get_todo(&self, todo_id: &str) -> Result<Value, Error> {
self.get(&["todos", todo_id], &[]).await
}
pub async fn create_todo<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
self.write(Method::POST, &["todos"], body).await
}
pub async fn update_todo<T: Serialize + ?Sized>(
&self,
todo_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::PATCH, &["todos", todo_id], body).await
}
pub async fn delete_todo(&self, todo_id: &str) -> Result<Value, Error> {
self.execute(Method::DELETE, &["todos", todo_id], None, &[], false)
.await
}
pub async fn list_notes(&self, todo_id: &str, query: &[(&str, &str)]) -> Result<Value, Error> {
self.get(&["todos", todo_id, "notes"], query).await
}
pub async fn add_note<T: Serialize + ?Sized>(
&self,
todo_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::POST, &["todos", todo_id, "notes"], body)
.await
}
pub async fn todo_subscription(&self, todo_id: &str) -> Result<Value, Error> {
self.get(&["todos", todo_id, "notification-subscription"], &[])
.await
}
pub async fn replace_todo_subscription<T: Serialize + ?Sized>(
&self,
todo_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(
Method::PUT,
&["todos", todo_id, "notification-subscription"],
body,
)
.await
}
pub async fn notification_settings(&self) -> Result<Value, Error> {
self.get(&["notification-settings"], &[]).await
}
pub async fn update_notification_settings<T: Serialize + ?Sized>(
&self,
body: &T,
) -> Result<Value, Error> {
self.write(Method::PUT, &["notification-settings"], body)
.await
}
pub async fn list_projects(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
self.get(&["projects"], query).await
}
pub async fn get_project(&self, project_id: &str) -> Result<Value, Error> {
self.get(&["projects", project_id], &[]).await
}
pub async fn create_project<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
self.write(Method::POST, &["projects"], body).await
}
pub async fn update_project<T: Serialize + ?Sized>(
&self,
project_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::PATCH, &["projects", project_id], body)
.await
}
pub async fn project_diary(&self, project_id: &str) -> Result<Value, Error> {
self.get(&["projects", project_id, "diary"], &[]).await
}
pub async fn replace_project_diary<T: Serialize + ?Sized>(
&self,
project_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::PUT, &["projects", project_id, "diary"], body)
.await
}
pub async fn project_tasks(
&self,
project_id: &str,
query: &[(&str, &str)],
) -> Result<Value, Error> {
self.get(&["projects", project_id, "tasks"], query).await
}
pub async fn project_entries(
&self,
project_id: &str,
query: &[(&str, &str)],
) -> Result<Value, Error> {
self.get(&["projects", project_id, "entries"], query).await
}
pub async fn create_project_task<T: Serialize + ?Sized>(
&self,
project_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::POST, &["projects", project_id, "tasks"], body)
.await
}
pub async fn update_project_task<T: Serialize + ?Sized>(
&self,
project_id: &str,
task_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(
Method::PATCH,
&["projects", project_id, "tasks", task_id],
body,
)
.await
}
pub async fn create_project_blocker<T: Serialize + ?Sized>(
&self,
project_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::POST, &["projects", project_id, "blockers"], body)
.await
}
pub async fn create_project_update<T: Serialize + ?Sized>(
&self,
project_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::POST, &["projects", project_id, "updates"], body)
.await
}
pub async fn complete_project<T: Serialize + ?Sized>(
&self,
project_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::POST, &["projects", project_id, "completion"], body)
.await
}
pub async fn list_test_environments(&self) -> Result<Value, Error> {
self.get(&["test-environments"], &[]).await
}
pub async fn create_test_environment<T: Serialize + ?Sized>(
&self,
body: &T,
) -> Result<Value, Error> {
self.write(Method::POST, &["test-environments"], body).await
}
pub async fn retrieve_test_environment_key(&self, id: &str) -> Result<Value, Error> {
self.get(&["test-environments", id, "key"], &[]).await
}
pub async fn rotate_test_environment(&self, id: &str) -> Result<Value, Error> {
self.execute(
Method::POST,
&["test-environments", id, "rotate"],
None,
&[],
false,
)
.await
}
pub async fn delete_test_environment(&self, id: &str) -> Result<Value, Error> {
self.execute(Method::DELETE, &["test-environments", id], None, &[], false)
.await
}
pub async fn restore_test_environment(&self, id: &str) -> Result<Value, Error> {
self.execute(
Method::POST,
&["test-environments", id, "restore"],
None,
&[],
false,
)
.await
}
pub async fn clean_test_environment(&self, id: &str) -> Result<Value, Error> {
self.execute(
Method::POST,
&["test-environments", id, "clean"],
None,
&[],
false,
)
.await
}
async fn get(&self, path: &[&str], query: &[(&str, &str)]) -> Result<Value, Error> {
self.execute(Method::GET, path, None, query, false).await
}
async fn write<T: Serialize + ?Sized>(
&self,
method: Method,
path: &[&str],
body: &T,
) -> Result<Value, Error> {
let body = serde_json::to_vec(body).map_err(Error::Decode)?;
self.execute(method, path, Some(body), &[], false).await
}
fn url(&self, path: &[&str], root: bool) -> Result<Url, Error> {
if path
.iter()
.any(|p| p.is_empty() || matches!(*p, "." | ".."))
{
return Err(Error::Invalid(
"empty and dot resource identifiers are forbidden",
));
}
let mut url = self.base.clone();
{
let mut segments = url.path_segments_mut().map_err(|_| Error::InvalidUrl)?;
if root {
segments.clear();
} else {
segments.pop_if_empty();
}
segments.extend(path);
}
Ok(url)
}
async fn execute(
&self,
method: Method,
path: &[&str],
body: Option<Vec<u8>>,
query: &[(&str, &str)],
root: bool,
) -> Result<Value, Error> {
let is_mutation = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
let mut req = self
.http
.request(method, self.url(path, root)?)
.query(query)
.header("x-commit-supported-versions", "1")
.header("x-commit-client", self.source)
.header(
"x-commit-telemetry",
if self.telemetry { "on" } else { "off" },
);
if !root {
if let Some(token) = &self.bearer {
req = req.bearer_auth(token.expose_secret());
}
if let Some(org) = &self.org_id {
req = req.header("x-org-id", org);
}
if let Some(key) = &self.test_key {
let key = key.expose_secret();
if !valid_testing_selector(key) {
return Err(Error::Invalid("invalid testing application secret"));
}
req = req.header("x-testing-environment-key", key);
}
}
if is_mutation {
let mutation = self.mutation.clone().unwrap_or_default();
req = req.header("idempotency-key", mutation.key);
if let Some(version) = mutation.version {
req = req.header("if-match", format!("\"{version}\""));
}
}
if let Some(body) = body {
req = req.header("content-type", "application/json").body(body);
}
let mut response = req.send().await.map_err(transport)?;
if response
.headers()
.get("x-commit-api-version")
.is_some_and(|v| v != "1")
{
return Err(Error::Invalid(
"server selected an unsupported API contract",
));
}
let status = response.status();
let request_id = response
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
const LIMIT: usize = 8 * 1024 * 1024;
if response.content_length().is_some_and(|n| n > LIMIT as u64) {
return Err(Error::ResponseTooLarge);
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(transport)? {
if bytes.len() + chunk.len() > LIMIT {
return Err(Error::ResponseTooLarge);
}
bytes.extend_from_slice(&chunk);
}
if !status.is_success() {
let parsed: Option<Value> = serde_json::from_slice(&bytes).ok();
let code = parsed
.as_ref()
.and_then(|v| v.pointer("/error/code"))
.and_then(Value::as_str)
.filter(|s| {
s.len() <= 128 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
})
.unwrap_or("http_error")
.to_owned();
return Err(Error::Api {
status,
code,
request_id,
});
}
if bytes.is_empty() {
return Ok(Value::Null);
}
serde_json::from_slice(&bytes).map_err(Error::Decode)
}
}
fn transport(error: reqwest::Error) -> Error {
Error::Transport(error.without_url())
}
fn decode<T: DeserializeOwned>(value: Value) -> Result<T, Error> {
serde_json::from_value(value).map_err(Error::Decode)
}
fn valid_testing_selector(key: &str) -> bool {
(key.len() == 32 && key.bytes().all(|b| b.is_ascii_alphanumeric()))
|| (key.len() == 47
&& key.starts_with("ask_")
&& key[4..]
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'))
}