Skip to main content

silicon_commit_client/
lib.rs

1//! Stateless client for Silicon Commit's public API.
2//!
3//! Supply organization and credential context explicitly. Keep a [`Mutation`]
4//! across retries of a logical write. Session persistence belongs to callers.
5
6use 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/// crates.io metadata used by the optional hourly updater.
16#[derive(Clone, Debug, Deserialize)]
17pub struct RegistryRelease {
18    pub version: String,
19}
20
21/// Returns the latest published client version when the registry is reachable.
22/// This is deliberately separate from API requests and never updates a running
23/// process; callers decide whether and how to install a newer binary/package.
24pub async fn latest_release() -> Result<RegistryRelease, Error> {
25    latest_release_for("silicon-commit-client").await
26}
27
28/// Returns the latest published CLI version when the registry is reachable.
29/// The CLI package is separate from the client library and therefore has its
30/// own registry check.
31pub 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/// Errors preserve status and correlation but never include credentials or raw proxy bodies.
69#[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    /// Whether the current access token needs renewal.
92    pub fn is_unauthenticated(&self) -> bool {
93        matches!(
94            self,
95            Self::Api {
96                status: StatusCode::UNAUTHORIZED,
97                ..
98            }
99        )
100    }
101}
102
103/// A logical mutation; reuse this value after an uncertain response.
104#[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/// Explicit connection/credential configuration; no session state is written to disk.
143#[derive(Clone, Debug)]
144pub struct Client {
145    http: HttpClient,
146    base: Url,
147    bearer: Option<SecretString>,
148    test_key: Option<SecretString>,
149    org_id: Option<String>,
150    mutation: Option<Mutation>,
151}
152
153/// Login tokens are serialized only when the caller explicitly saves a session.
154#[derive(Clone, Deserialize, Serialize)]
155pub struct SessionTokens {
156    pub access_token: String,
157    pub refresh_token: String,
158    pub token_type: Value,
159    pub expires_in: i64,
160    pub scope: String,
161    pub actor: Value,
162    pub org_id: Option<String>,
163}
164impl fmt::Debug for SessionTokens {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        f.debug_struct("SessionTokens")
167            .field("tokens", &"<redacted>")
168            .field("org_id", &self.org_id)
169            .finish()
170    }
171}
172
173impl Client {
174    /// Accepts a bare backend origin, /api/v1, or /api/v1/.
175    pub fn new(base_url: &str) -> Result<Self, Error> {
176        let mut base = Url::parse(base_url).map_err(|_| Error::InvalidUrl)?;
177        if !matches!(base.scheme(), "http" | "https")
178            || base.host_str().is_none()
179            || !base.username().is_empty()
180            || base.password().is_some()
181            || base.query().is_some()
182            || base.fragment().is_some()
183            || !matches!(base.path(), "" | "/" | "/api/v1" | "/api/v1/")
184        {
185            return Err(Error::InvalidUrl);
186        }
187        base.set_path("/api/v1/");
188        let http = HttpClient::builder()
189            .redirect(reqwest::redirect::Policy::none())
190            .connect_timeout(Duration::from_secs(5))
191            .timeout(Duration::from_secs(30))
192            .user_agent(concat!("silicon-commit-client/", env!("CARGO_PKG_VERSION")))
193            .build()
194            .map_err(transport)?;
195        Ok(Self {
196            http,
197            base,
198            bearer: None,
199            test_key: None,
200            org_id: None,
201            mutation: None,
202        })
203    }
204    pub fn with_bearer(mut self, token: impl Into<String>) -> Self {
205        self.bearer = Some(SecretString::from(token.into()));
206        self
207    }
208    pub fn with_test_key(mut self, key: impl Into<String>) -> Result<Self, Error> {
209        let key = key.into();
210        if key.len() != 32 || !key.bytes().all(|b| b.is_ascii_alphanumeric()) {
211            return Err(Error::Invalid(
212                "test-environment key must be exactly 32 alphanumeric characters",
213            ));
214        }
215        self.test_key = Some(SecretString::from(key));
216        Ok(self)
217    }
218    pub fn with_org_id(mut self, org_id: impl Into<String>) -> Self {
219        self.org_id = Some(org_id.into());
220        self
221    }
222    /// Attaches retry/precondition inputs to requests made through this clone.
223    pub fn with_mutation(mut self, mutation: Mutation) -> Self {
224        self.mutation = Some(mutation);
225        self
226    }
227    pub fn base_url(&self) -> &Url {
228        &self.base
229    }
230    pub fn new_idempotency_key() -> String {
231        Mutation::new().key
232    }
233    pub async fn health(&self) -> Result<Value, Error> {
234        self.execute(Method::GET, &["healthz"], None, &[], true)
235            .await
236    }
237    pub async fn ready(&self) -> Result<Value, Error> {
238        self.execute(Method::GET, &["readyz"], None, &[], true)
239            .await
240    }
241    pub async fn version(&self) -> Result<Value, Error> {
242        // `/version` is intentionally public; do not disclose caller context.
243        let mut public = self.clone();
244        public.bearer = None;
245        public.org_id = None;
246        public.test_key = None;
247        public.get(&["version"], &[]).await
248    }
249    /// Public IAM application metadata. Caller credentials and test context are omitted.
250    pub async fn iam(&self) -> Result<Value, Error> {
251        let mut public = self.clone();
252        public.bearer = None;
253        public.org_id = None;
254        public.test_key = None;
255        public.get(&["iam"], &[]).await
256    }
257    /// Verify the current login against IAM and return its public actor identity.
258    /// Missing or rejected credentials return `authenticated: false`; transport,
259    /// permission, and server failures remain errors. No tokens are returned.
260    pub async fn login_status(&self) -> Result<Value, Error> {
261        if self
262            .bearer
263            .as_ref()
264            .is_none_or(|token| token.expose_secret().is_empty())
265        {
266            return Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}));
267        }
268        match self.get(&["auth", "status"], &[]).await {
269            Err(error) if error.is_unauthenticated() => {
270                Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}))
271            }
272            result => result,
273        }
274    }
275    pub async fn login_with_slt(&self, slt: &str) -> Result<SessionTokens, Error> {
276        decode(
277            self.write(
278                Method::POST,
279                &["auth", "login"],
280                &serde_json::json!({"slt":slt}),
281            )
282            .await?,
283        )
284    }
285    pub async fn refresh_session(&self, refresh_token: &str) -> Result<SessionTokens, Error> {
286        decode(
287            self.write(
288                Method::POST,
289                &["auth", "refresh"],
290                &serde_json::json!({"refresh_token":refresh_token}),
291            )
292            .await?,
293        )
294    }
295    pub async fn logout(&self, token: &str) -> Result<(), Error> {
296        self.write(
297            Method::POST,
298            &["auth", "logout"],
299            &serde_json::json!({"token":token}),
300        )
301        .await?;
302        Ok(())
303    }
304    /// GET /api/v1/todos.
305    pub async fn list_todos(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
306        self.get(&["todos"], query).await
307    }
308    /// GET /api/v1/todos/{todo_id}.
309    pub async fn get_todo(&self, todo_id: &str) -> Result<Value, Error> {
310        self.get(&["todos", todo_id], &[]).await
311    }
312    /// POST /api/v1/todos.
313    pub async fn create_todo<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
314        self.write(Method::POST, &["todos"], body).await
315    }
316    /// PATCH /api/v1/todos/{todo_id}.
317    pub async fn update_todo<T: Serialize + ?Sized>(
318        &self,
319        todo_id: &str,
320        body: &T,
321    ) -> Result<Value, Error> {
322        self.write(Method::PATCH, &["todos", todo_id], body).await
323    }
324    /// DELETE /api/v1/todos/{todo_id}.
325    pub async fn delete_todo(&self, todo_id: &str) -> Result<Value, Error> {
326        self.execute(Method::DELETE, &["todos", todo_id], None, &[], false)
327            .await
328    }
329    /// GET /api/v1/todos/{todo_id}/notes.
330    pub async fn list_notes(&self, todo_id: &str, query: &[(&str, &str)]) -> Result<Value, Error> {
331        self.get(&["todos", todo_id, "notes"], query).await
332    }
333    /// POST /api/v1/todos/{todo_id}/notes.
334    pub async fn add_note<T: Serialize + ?Sized>(
335        &self,
336        todo_id: &str,
337        body: &T,
338    ) -> Result<Value, Error> {
339        self.write(Method::POST, &["todos", todo_id, "notes"], body)
340            .await
341    }
342    /// GET /api/v1/todos/{todo_id}/notification-subscription.
343    pub async fn todo_subscription(&self, todo_id: &str) -> Result<Value, Error> {
344        self.get(&["todos", todo_id, "notification-subscription"], &[])
345            .await
346    }
347    /// PUT /api/v1/todos/{todo_id}/notification-subscription.
348    pub async fn replace_todo_subscription<T: Serialize + ?Sized>(
349        &self,
350        todo_id: &str,
351        body: &T,
352    ) -> Result<Value, Error> {
353        self.write(
354            Method::PUT,
355            &["todos", todo_id, "notification-subscription"],
356            body,
357        )
358        .await
359    }
360    /// GET /api/v1/notification-settings.
361    pub async fn notification_settings(&self) -> Result<Value, Error> {
362        self.get(&["notification-settings"], &[]).await
363    }
364    /// PUT /api/v1/notification-settings.
365    pub async fn update_notification_settings<T: Serialize + ?Sized>(
366        &self,
367        body: &T,
368    ) -> Result<Value, Error> {
369        self.write(Method::PUT, &["notification-settings"], body)
370            .await
371    }
372    /// GET /api/v1/projects.
373    pub async fn list_projects(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
374        self.get(&["projects"], query).await
375    }
376    /// GET /api/v1/projects/{project_id}.
377    pub async fn get_project(&self, project_id: &str) -> Result<Value, Error> {
378        self.get(&["projects", project_id], &[]).await
379    }
380    /// POST /api/v1/projects.
381    pub async fn create_project<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
382        self.write(Method::POST, &["projects"], body).await
383    }
384    /// PATCH /api/v1/projects/{project_id}.
385    pub async fn update_project<T: Serialize + ?Sized>(
386        &self,
387        project_id: &str,
388        body: &T,
389    ) -> Result<Value, Error> {
390        self.write(Method::PATCH, &["projects", project_id], body)
391            .await
392    }
393    /// GET /api/v1/projects/{project_id}/diary.
394    pub async fn project_diary(&self, project_id: &str) -> Result<Value, Error> {
395        self.get(&["projects", project_id, "diary"], &[]).await
396    }
397    /// PUT /api/v1/projects/{project_id}/diary.
398    pub async fn replace_project_diary<T: Serialize + ?Sized>(
399        &self,
400        project_id: &str,
401        body: &T,
402    ) -> Result<Value, Error> {
403        self.write(Method::PUT, &["projects", project_id, "diary"], body)
404            .await
405    }
406    /// GET /api/v1/projects/{project_id}/tasks.
407    pub async fn project_tasks(
408        &self,
409        project_id: &str,
410        query: &[(&str, &str)],
411    ) -> Result<Value, Error> {
412        self.get(&["projects", project_id, "tasks"], query).await
413    }
414    /// GET /api/v1/projects/{project_id}/entries.
415    pub async fn project_entries(
416        &self,
417        project_id: &str,
418        query: &[(&str, &str)],
419    ) -> Result<Value, Error> {
420        self.get(&["projects", project_id, "entries"], query).await
421    }
422    /// POST /api/v1/projects/{project_id}/tasks.
423    pub async fn create_project_task<T: Serialize + ?Sized>(
424        &self,
425        project_id: &str,
426        body: &T,
427    ) -> Result<Value, Error> {
428        self.write(Method::POST, &["projects", project_id, "tasks"], body)
429            .await
430    }
431    /// PATCH /api/v1/projects/{project_id}/tasks/{task_id}.
432    pub async fn update_project_task<T: Serialize + ?Sized>(
433        &self,
434        project_id: &str,
435        task_id: &str,
436        body: &T,
437    ) -> Result<Value, Error> {
438        self.write(
439            Method::PATCH,
440            &["projects", project_id, "tasks", task_id],
441            body,
442        )
443        .await
444    }
445    /// POST /api/v1/projects/{project_id}/blockers.
446    pub async fn create_project_blocker<T: Serialize + ?Sized>(
447        &self,
448        project_id: &str,
449        body: &T,
450    ) -> Result<Value, Error> {
451        self.write(Method::POST, &["projects", project_id, "blockers"], body)
452            .await
453    }
454    /// POST /api/v1/projects/{project_id}/updates.
455    pub async fn create_project_update<T: Serialize + ?Sized>(
456        &self,
457        project_id: &str,
458        body: &T,
459    ) -> Result<Value, Error> {
460        self.write(Method::POST, &["projects", project_id, "updates"], body)
461            .await
462    }
463    /// POST /api/v1/projects/{project_id}/completion.
464    pub async fn complete_project<T: Serialize + ?Sized>(
465        &self,
466        project_id: &str,
467        body: &T,
468    ) -> Result<Value, Error> {
469        self.write(Method::POST, &["projects", project_id, "completion"], body)
470            .await
471    }
472    /// GET /api/v1/test-environments.
473    pub async fn list_test_environments(&self) -> Result<Value, Error> {
474        self.get(&["test-environments"], &[]).await
475    }
476    /// POST /api/v1/test-environments.
477    pub async fn create_test_environment<T: Serialize + ?Sized>(
478        &self,
479        body: &T,
480    ) -> Result<Value, Error> {
481        self.write(Method::POST, &["test-environments"], body).await
482    }
483    /// GET /api/v1/test-environments/{id}/key.
484    pub async fn retrieve_test_environment_key(&self, id: &str) -> Result<Value, Error> {
485        self.get(&["test-environments", id, "key"], &[]).await
486    }
487    /// POST /api/v1/test-environments/{id}/rotate.
488    pub async fn rotate_test_environment(&self, id: &str) -> Result<Value, Error> {
489        self.execute(
490            Method::POST,
491            &["test-environments", id, "rotate"],
492            None,
493            &[],
494            false,
495        )
496        .await
497    }
498    /// DELETE /api/v1/test-environments/{id}.
499    pub async fn delete_test_environment(&self, id: &str) -> Result<Value, Error> {
500        self.execute(Method::DELETE, &["test-environments", id], None, &[], false)
501            .await
502    }
503    pub async fn restore_test_environment(&self, id: &str) -> Result<Value, Error> {
504        self.execute(
505            Method::POST,
506            &["test-environments", id, "restore"],
507            None,
508            &[],
509            false,
510        )
511        .await
512    }
513    pub async fn clean_test_environment(&self, id: &str) -> Result<Value, Error> {
514        self.execute(
515            Method::POST,
516            &["test-environments", id, "clean"],
517            None,
518            &[],
519            false,
520        )
521        .await
522    }
523    async fn get(&self, path: &[&str], query: &[(&str, &str)]) -> Result<Value, Error> {
524        self.execute(Method::GET, path, None, query, false).await
525    }
526    async fn write<T: Serialize + ?Sized>(
527        &self,
528        method: Method,
529        path: &[&str],
530        body: &T,
531    ) -> Result<Value, Error> {
532        let body = serde_json::to_vec(body).map_err(Error::Decode)?;
533        self.execute(method, path, Some(body), &[], false).await
534    }
535    fn url(&self, path: &[&str], root: bool) -> Result<Url, Error> {
536        if path
537            .iter()
538            .any(|p| p.is_empty() || matches!(*p, "." | ".."))
539        {
540            return Err(Error::Invalid(
541                "empty and dot resource identifiers are forbidden",
542            ));
543        }
544        let mut url = self.base.clone();
545        {
546            let mut segments = url.path_segments_mut().map_err(|_| Error::InvalidUrl)?;
547            if root {
548                segments.clear();
549            } else {
550                segments.pop_if_empty();
551            }
552            segments.extend(path);
553        }
554        Ok(url)
555    }
556    async fn execute(
557        &self,
558        method: Method,
559        path: &[&str],
560        body: Option<Vec<u8>>,
561        query: &[(&str, &str)],
562        root: bool,
563    ) -> Result<Value, Error> {
564        let is_mutation = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
565        let mut req = self
566            .http
567            .request(method, self.url(path, root)?)
568            .query(query);
569        if !root {
570            if let Some(token) = &self.bearer {
571                req = req.bearer_auth(token.expose_secret());
572            }
573            if let Some(org) = &self.org_id {
574                req = req.header("x-org-id", org);
575            }
576            if let Some(key) = &self.test_key {
577                let key = key.expose_secret();
578                if key.len() != 32 || !key.bytes().all(|b| b.is_ascii_alphanumeric()) {
579                    return Err(Error::Invalid(
580                        "testing key must be exactly 32 alphanumeric characters",
581                    ));
582                }
583                req = req.header("x-testing-environment-key", key);
584            }
585        }
586        if is_mutation {
587            let mutation = self.mutation.clone().unwrap_or_default();
588            req = req.header("idempotency-key", mutation.key);
589            if let Some(version) = mutation.version {
590                req = req.header("if-match", format!("\"{version}\""));
591            }
592        }
593        if let Some(body) = body {
594            req = req.header("content-type", "application/json").body(body);
595        }
596        let mut response = req.send().await.map_err(transport)?;
597        let status = response.status();
598        let request_id = response
599            .headers()
600            .get("x-request-id")
601            .and_then(|v| v.to_str().ok())
602            .map(str::to_owned);
603        const LIMIT: usize = 8 * 1024 * 1024;
604        if response.content_length().is_some_and(|n| n > LIMIT as u64) {
605            return Err(Error::ResponseTooLarge);
606        }
607        let mut bytes = Vec::new();
608        while let Some(chunk) = response.chunk().await.map_err(transport)? {
609            if bytes.len() + chunk.len() > LIMIT {
610                return Err(Error::ResponseTooLarge);
611            }
612            bytes.extend_from_slice(&chunk);
613        }
614        if !status.is_success() {
615            let parsed: Option<Value> = serde_json::from_slice(&bytes).ok();
616            let code = parsed
617                .as_ref()
618                .and_then(|v| v.pointer("/error/code"))
619                .and_then(Value::as_str)
620                .filter(|s| {
621                    s.len() <= 128 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
622                })
623                .unwrap_or("http_error")
624                .to_owned();
625            return Err(Error::Api {
626                status,
627                code,
628                request_id,
629            });
630        }
631        if bytes.is_empty() {
632            return Ok(Value::Null);
633        }
634        serde_json::from_slice(&bytes).map_err(Error::Decode)
635    }
636}
637fn transport(error: reqwest::Error) -> Error {
638    Error::Transport(error.without_url())
639}
640fn decode<T: DeserializeOwned>(value: Value) -> Result<T, Error> {
641    serde_json::from_value(value).map_err(Error::Decode)
642}