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    telemetry: bool,
149    source: &'static str,
150    test_key: Option<SecretString>,
151    org_id: Option<String>,
152    mutation: Option<Mutation>,
153}
154
155/// Login tokens are serialized only when the caller explicitly saves a session.
156#[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}
166/// Validates the immutable actor and organization of an ordinary IAM 5 session.
167pub fn validate_session_context(actor: &Value, org_id: Option<&str>) -> Result<(), Error> {
168    let (prefix, maximum) = match actor["type"].as_str() {
169        Some("carbon") => ("c:", 30),
170        Some("silicon") => ("si:", 50),
171        _ => return Err(Error::Invalid("session requires a Carbon or Silicon actor")),
172    };
173    let valid_id = actor["public_id"]
174        .as_str()
175        .and_then(|id| id.strip_prefix(prefix))
176        .is_some_and(|id| {
177            (3..=maximum).contains(&id.len())
178                && id.bytes().all(|byte| {
179                    byte.is_ascii_lowercase()
180                        || byte.is_ascii_digit()
181                        || matches!(byte, b'_' | b'-')
182                })
183        });
184    let valid_org = org_id.is_some_and(|org| {
185        (3..=50).contains(&org.len())
186            && org.bytes().all(|byte| {
187                byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
188            })
189    });
190    if !valid_id || !valid_org {
191        return Err(Error::Invalid(
192            "session requires a canonical actor and one organization; sign in again",
193        ));
194    }
195    Ok(())
196}
197impl SessionTokens {
198    /// Rejects unscoped, malformed, or delegated credentials as ordinary sessions.
199    pub fn validate(&self) -> Result<(), Error> {
200        validate_session_context(&self.actor, self.org_id.as_deref())?;
201        if self.access_token.is_empty()
202            || self.refresh_token.is_empty()
203            || self.expires_in <= 0
204            || self.token_type != "Bearer"
205            || self
206                .scope
207                .split_whitespace()
208                .any(|scope| scope.starts_with("obo:"))
209        {
210            return Err(Error::Invalid("invalid ordinary IAM 5 token response"));
211        }
212        Ok(())
213    }
214}
215impl fmt::Debug for SessionTokens {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        f.debug_struct("SessionTokens")
218            .field("tokens", &"<redacted>")
219            .field("org_id", &self.org_id)
220            .finish()
221    }
222}
223
224impl Client {
225    /// Accepts a bare backend origin, /api/v1, or /api/v1/.
226    pub fn new(base_url: &str) -> Result<Self, Error> {
227        let mut base = Url::parse(base_url).map_err(|_| Error::InvalidUrl)?;
228        if !matches!(base.scheme(), "http" | "https")
229            || base.host_str().is_none()
230            || !base.username().is_empty()
231            || base.password().is_some()
232            || base.query().is_some()
233            || base.fragment().is_some()
234            || !matches!(base.path(), "" | "/" | "/api/v1" | "/api/v1/")
235        {
236            return Err(Error::InvalidUrl);
237        }
238        base.set_path("/api/v1/");
239        let http = HttpClient::builder()
240            .redirect(reqwest::redirect::Policy::none())
241            .connect_timeout(Duration::from_secs(5))
242            .timeout(Duration::from_secs(30))
243            .user_agent(concat!("silicon-commit-client/", env!("CARGO_PKG_VERSION")))
244            .build()
245            .map_err(transport)?;
246        Ok(Self {
247            http,
248            base,
249            bearer: None,
250            telemetry: true,
251            source: "rust-client",
252            test_key: None,
253            org_id: None,
254            mutation: None,
255        })
256    }
257    pub fn with_bearer(mut self, token: impl Into<String>) -> Self {
258        self.bearer = Some(SecretString::from(token.into()));
259        self
260    }
261    pub fn with_test_key(mut self, key: impl Into<String>) -> Result<Self, Error> {
262        let key = key.into();
263        if !valid_testing_selector(&key) {
264            return Err(Error::Invalid(
265                "test selector must be an IAM test app_secret or a legacy 32-character Commit key",
266            ));
267        }
268        self.test_key = Some(SecretString::from(key));
269        Ok(self)
270    }
271    /// Selects an IAM sandbox using only the imported application's secret.
272    pub fn with_test_app_secret(self, secret: impl Into<String>) -> Result<Self, Error> {
273        self.with_test_key(secret)
274    }
275    /// Live metadata for the selected environment. Never returns a secret.
276    pub async fn contracts(&self) -> Result<Value, Error> {
277        self.get(&["contracts"], &[]).await
278    }
279    /// Opts this client instance out of diagnostic telemetry.
280    pub fn with_telemetry(mut self, enabled: bool) -> Self {
281        self.telemetry = enabled;
282        self
283    }
284    /// Identifies the interface in request diagnostics; no command arguments are sent.
285    pub fn with_source(mut self, source: &'static str) -> Result<Self, Error> {
286        if !matches!(source, "cli" | "browser" | "daemon" | "rust-client") {
287            return Err(Error::Invalid("unknown client source"));
288        }
289        self.source = source;
290        Ok(self)
291    }
292    /// Reads this identity's organization email preferences.
293    pub async fn email_settings(&self) -> Result<Value, Error> {
294        self.get(&["email-settings"], &[]).await
295    }
296    /// Replaces the organization email and event subscriptions.
297    pub async fn set_email_settings(&self, body: &Value) -> Result<Value, Error> {
298        self.write(Method::PUT, &["email-settings"], body).await
299    }
300    /// Queues a bug report for Commit maintainers; preserve the mutation across retries.
301    pub async fn report(&self, body: &Value) -> Result<Value, Error> {
302        self.write(Method::POST, &["reports"], body).await
303    }
304    pub async fn testing_context(&self) -> Result<Value, Error> {
305        self.get(&["testing-context"], &[]).await
306    }
307    /// Atomically take an unassigned task.
308    pub async fn claim_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
309        self.write(
310            Method::POST,
311            &["projects", project, "tasks", task, "claim"],
312            &serde_json::json!({}),
313        )
314        .await
315    }
316    /// Remove a task subtree and its linked todos.
317    pub async fn delete_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
318        self.execute(
319            Method::DELETE,
320            &["projects", project, "tasks", task],
321            None,
322            &[],
323            false,
324        )
325        .await
326    }
327    /// Retained project version metadata, newest first.
328    pub async fn project_versions(
329        &self,
330        project: &str,
331        query: &[(&str, &str)],
332    ) -> Result<Value, Error> {
333        self.get(&["projects", project, "versions"], query).await
334    }
335    /// One complete retained project snapshot.
336    pub async fn project_version(&self, project: &str, version: i64) -> Result<Value, Error> {
337        self.get(
338            &["projects", project, "versions", &version.to_string()],
339            &[],
340        )
341        .await
342    }
343    pub fn with_org_id(mut self, org_id: impl Into<String>) -> Self {
344        self.org_id = Some(org_id.into());
345        self
346    }
347    /// Attaches retry/precondition inputs to requests made through this clone.
348    pub fn with_mutation(mut self, mutation: Mutation) -> Self {
349        self.mutation = Some(mutation);
350        self
351    }
352    pub fn base_url(&self) -> &Url {
353        &self.base
354    }
355    pub fn new_idempotency_key() -> String {
356        Mutation::new().key
357    }
358    pub async fn health(&self) -> Result<Value, Error> {
359        self.execute(Method::GET, &["healthz"], None, &[], true)
360            .await
361    }
362    pub async fn ready(&self) -> Result<Value, Error> {
363        self.execute(Method::GET, &["readyz"], None, &[], true)
364            .await
365    }
366    pub async fn version(&self) -> Result<Value, Error> {
367        // `/version` is intentionally public; do not disclose caller context.
368        let mut public = self.clone();
369        public.bearer = None;
370        public.org_id = None;
371        public.test_key = None;
372        public.get(&["version"], &[]).await
373    }
374    /// Public IAM application metadata. Caller credentials and test context are omitted.
375    pub async fn iam(&self) -> Result<Value, Error> {
376        let mut public = self.clone();
377        public.bearer = None;
378        public.org_id = None;
379        public.test_key = None;
380        public.get(&["iam"], &[]).await
381    }
382    /// Verify the current login against IAM and return its public actor identity.
383    /// Missing or rejected credentials return `authenticated: false`; transport,
384    /// permission, and server failures remain errors. No tokens are returned.
385    pub async fn login_status(&self) -> Result<Value, Error> {
386        if self
387            .bearer
388            .as_ref()
389            .is_none_or(|token| token.expose_secret().is_empty())
390        {
391            return Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}));
392        }
393        match self.get(&["auth", "status"], &[]).await {
394            Err(error) if error.is_unauthenticated() => {
395                Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}))
396            }
397            result => result,
398        }
399    }
400    pub async fn login_with_slt(&self, slt: &str) -> Result<SessionTokens, Error> {
401        // Explicit selection is needed when a testing actor belongs to several organizations.
402        let mut body = serde_json::json!({"slt":slt});
403        if let Some(org) = &self.org_id {
404            body["org_id"] = org.clone().into();
405        }
406        let mut broker = self.clone();
407        broker.bearer = None;
408        let tokens: SessionTokens = decode(
409            broker
410                .write(Method::POST, &["auth", "login"], &body)
411                .await?,
412        )?;
413        tokens.validate()?;
414        if self
415            .org_id
416            .as_ref()
417            .is_some_and(|org| Some(org) != tokens.org_id.as_ref())
418        {
419            return Err(Error::Invalid("login returned a different organization"));
420        }
421        Ok(tokens)
422    }
423    /// The caller must additionally compare the returned actor and org with the saved family.
424    pub async fn refresh_session(&self, refresh_token: &str) -> Result<SessionTokens, Error> {
425        let mut broker = self.clone();
426        broker.bearer = None;
427        broker.org_id = None;
428        let tokens: SessionTokens = decode(
429            broker
430                .write(
431                    Method::POST,
432                    &["auth", "refresh"],
433                    &serde_json::json!({"refresh_token":refresh_token}),
434                )
435                .await?,
436        )?;
437        tokens.validate()?;
438        if self
439            .org_id
440            .as_ref()
441            .is_some_and(|org| Some(org) != tokens.org_id.as_ref())
442        {
443            return Err(Error::Invalid("refresh returned a different organization"));
444        }
445        Ok(tokens)
446    }
447    pub async fn logout(&self, token: &str) -> Result<(), Error> {
448        self.write(
449            Method::POST,
450            &["auth", "logout"],
451            &serde_json::json!({"token":token}),
452        )
453        .await?;
454        Ok(())
455    }
456    /// GET /api/v1/todos.
457    pub async fn list_todos(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
458        self.get(&["todos"], query).await
459    }
460    /// GET /api/v1/todos/{todo_id}.
461    pub async fn get_todo(&self, todo_id: &str) -> Result<Value, Error> {
462        self.get(&["todos", todo_id], &[]).await
463    }
464    /// POST /api/v1/todos.
465    pub async fn create_todo<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
466        self.write(Method::POST, &["todos"], body).await
467    }
468    /// PATCH /api/v1/todos/{todo_id}.
469    pub async fn update_todo<T: Serialize + ?Sized>(
470        &self,
471        todo_id: &str,
472        body: &T,
473    ) -> Result<Value, Error> {
474        self.write(Method::PATCH, &["todos", todo_id], body).await
475    }
476    /// DELETE /api/v1/todos/{todo_id}.
477    pub async fn delete_todo(&self, todo_id: &str) -> Result<Value, Error> {
478        self.execute(Method::DELETE, &["todos", todo_id], None, &[], false)
479            .await
480    }
481    /// GET /api/v1/todos/{todo_id}/notes.
482    pub async fn list_notes(&self, todo_id: &str, query: &[(&str, &str)]) -> Result<Value, Error> {
483        self.get(&["todos", todo_id, "notes"], query).await
484    }
485    /// POST /api/v1/todos/{todo_id}/notes.
486    pub async fn add_note<T: Serialize + ?Sized>(
487        &self,
488        todo_id: &str,
489        body: &T,
490    ) -> Result<Value, Error> {
491        self.write(Method::POST, &["todos", todo_id, "notes"], body)
492            .await
493    }
494    /// GET /api/v1/todos/{todo_id}/notification-subscription.
495    pub async fn todo_subscription(&self, todo_id: &str) -> Result<Value, Error> {
496        self.get(&["todos", todo_id, "notification-subscription"], &[])
497            .await
498    }
499    /// PUT /api/v1/todos/{todo_id}/notification-subscription.
500    pub async fn replace_todo_subscription<T: Serialize + ?Sized>(
501        &self,
502        todo_id: &str,
503        body: &T,
504    ) -> Result<Value, Error> {
505        self.write(
506            Method::PUT,
507            &["todos", todo_id, "notification-subscription"],
508            body,
509        )
510        .await
511    }
512    /// GET /api/v1/notification-settings.
513    pub async fn notification_settings(&self) -> Result<Value, Error> {
514        self.get(&["notification-settings"], &[]).await
515    }
516    /// PUT /api/v1/notification-settings.
517    pub async fn update_notification_settings<T: Serialize + ?Sized>(
518        &self,
519        body: &T,
520    ) -> Result<Value, Error> {
521        self.write(Method::PUT, &["notification-settings"], body)
522            .await
523    }
524    /// GET /api/v1/projects.
525    pub async fn list_projects(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
526        self.get(&["projects"], query).await
527    }
528    /// GET /api/v1/projects/{project_id}.
529    pub async fn get_project(&self, project_id: &str) -> Result<Value, Error> {
530        self.get(&["projects", project_id], &[]).await
531    }
532    /// POST /api/v1/projects.
533    pub async fn create_project<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
534        self.write(Method::POST, &["projects"], body).await
535    }
536    /// PATCH /api/v1/projects/{project_id}.
537    pub async fn update_project<T: Serialize + ?Sized>(
538        &self,
539        project_id: &str,
540        body: &T,
541    ) -> Result<Value, Error> {
542        self.write(Method::PATCH, &["projects", project_id], body)
543            .await
544    }
545    /// GET /api/v1/projects/{project_id}/diary.
546    pub async fn project_diary(&self, project_id: &str) -> Result<Value, Error> {
547        self.get(&["projects", project_id, "diary"], &[]).await
548    }
549    /// PUT /api/v1/projects/{project_id}/diary.
550    pub async fn replace_project_diary<T: Serialize + ?Sized>(
551        &self,
552        project_id: &str,
553        body: &T,
554    ) -> Result<Value, Error> {
555        self.write(Method::PUT, &["projects", project_id, "diary"], body)
556            .await
557    }
558    /// GET /api/v1/projects/{project_id}/tasks.
559    pub async fn project_tasks(
560        &self,
561        project_id: &str,
562        query: &[(&str, &str)],
563    ) -> Result<Value, Error> {
564        self.get(&["projects", project_id, "tasks"], query).await
565    }
566    /// GET /api/v1/projects/{project_id}/entries.
567    pub async fn project_entries(
568        &self,
569        project_id: &str,
570        query: &[(&str, &str)],
571    ) -> Result<Value, Error> {
572        self.get(&["projects", project_id, "entries"], query).await
573    }
574    /// POST /api/v1/projects/{project_id}/tasks.
575    pub async fn create_project_task<T: Serialize + ?Sized>(
576        &self,
577        project_id: &str,
578        body: &T,
579    ) -> Result<Value, Error> {
580        self.write(Method::POST, &["projects", project_id, "tasks"], body)
581            .await
582    }
583    /// PATCH /api/v1/projects/{project_id}/tasks/{task_id}.
584    pub async fn update_project_task<T: Serialize + ?Sized>(
585        &self,
586        project_id: &str,
587        task_id: &str,
588        body: &T,
589    ) -> Result<Value, Error> {
590        self.write(
591            Method::PATCH,
592            &["projects", project_id, "tasks", task_id],
593            body,
594        )
595        .await
596    }
597    /// POST /api/v1/projects/{project_id}/blockers.
598    pub async fn create_project_blocker<T: Serialize + ?Sized>(
599        &self,
600        project_id: &str,
601        body: &T,
602    ) -> Result<Value, Error> {
603        self.write(Method::POST, &["projects", project_id, "blockers"], body)
604            .await
605    }
606    /// POST /api/v1/projects/{project_id}/updates.
607    pub async fn create_project_update<T: Serialize + ?Sized>(
608        &self,
609        project_id: &str,
610        body: &T,
611    ) -> Result<Value, Error> {
612        self.write(Method::POST, &["projects", project_id, "updates"], body)
613            .await
614    }
615    /// POST /api/v1/projects/{project_id}/completion.
616    pub async fn complete_project<T: Serialize + ?Sized>(
617        &self,
618        project_id: &str,
619        body: &T,
620    ) -> Result<Value, Error> {
621        self.write(Method::POST, &["projects", project_id, "completion"], body)
622            .await
623    }
624    /// GET /api/v1/test-environments.
625    pub async fn list_test_environments(&self) -> Result<Value, Error> {
626        self.get(&["test-environments"], &[]).await
627    }
628    /// Legacy creation route, retained for source compatibility.
629    /// New creation returns `honeycomb_manages_testing_lifecycle`; create through Honeycomb.
630    pub async fn create_test_environment<T: Serialize + ?Sized>(
631        &self,
632        body: &T,
633    ) -> Result<Value, Error> {
634        self.write(Method::POST, &["test-environments"], body).await
635    }
636    /// GET /api/v1/test-environments/{id}/key.
637    pub async fn retrieve_test_environment_key(&self, id: &str) -> Result<Value, Error> {
638        self.get(&["test-environments", id, "key"], &[]).await
639    }
640    /// POST /api/v1/test-environments/{id}/rotate.
641    pub async fn rotate_test_environment(&self, id: &str) -> Result<Value, Error> {
642        self.execute(
643            Method::POST,
644            &["test-environments", id, "rotate"],
645            None,
646            &[],
647            false,
648        )
649        .await
650    }
651    /// DELETE /api/v1/test-environments/{id}.
652    pub async fn delete_test_environment(&self, id: &str) -> Result<Value, Error> {
653        self.execute(Method::DELETE, &["test-environments", id], None, &[], false)
654            .await
655    }
656    pub async fn restore_test_environment(&self, id: &str) -> Result<Value, Error> {
657        self.execute(
658            Method::POST,
659            &["test-environments", id, "restore"],
660            None,
661            &[],
662            false,
663        )
664        .await
665    }
666    pub async fn clean_test_environment(&self, id: &str) -> Result<Value, Error> {
667        self.execute(
668            Method::POST,
669            &["test-environments", id, "clean"],
670            None,
671            &[],
672            false,
673        )
674        .await
675    }
676    async fn get(&self, path: &[&str], query: &[(&str, &str)]) -> Result<Value, Error> {
677        self.execute(Method::GET, path, None, query, false).await
678    }
679    async fn write<T: Serialize + ?Sized>(
680        &self,
681        method: Method,
682        path: &[&str],
683        body: &T,
684    ) -> Result<Value, Error> {
685        let body = serde_json::to_vec(body).map_err(Error::Decode)?;
686        self.execute(method, path, Some(body), &[], false).await
687    }
688    fn url(&self, path: &[&str], root: bool) -> Result<Url, Error> {
689        if path
690            .iter()
691            .any(|p| p.is_empty() || matches!(*p, "." | ".."))
692        {
693            return Err(Error::Invalid(
694                "empty and dot resource identifiers are forbidden",
695            ));
696        }
697        let mut url = self.base.clone();
698        {
699            let mut segments = url.path_segments_mut().map_err(|_| Error::InvalidUrl)?;
700            if root {
701                segments.clear();
702            } else {
703                segments.pop_if_empty();
704            }
705            segments.extend(path);
706        }
707        Ok(url)
708    }
709    async fn execute(
710        &self,
711        method: Method,
712        path: &[&str],
713        body: Option<Vec<u8>>,
714        query: &[(&str, &str)],
715        root: bool,
716    ) -> Result<Value, Error> {
717        let is_mutation = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
718        let mut req = self
719            .http
720            .request(method, self.url(path, root)?)
721            .query(query)
722            .header("x-commit-supported-versions", "1")
723            .header("x-commit-client", self.source)
724            .header(
725                "x-commit-telemetry",
726                if self.telemetry { "on" } else { "off" },
727            );
728        if !root {
729            if let Some(token) = &self.bearer {
730                req = req.bearer_auth(token.expose_secret());
731            }
732            if let Some(org) = &self.org_id {
733                req = req.header("x-org-id", org);
734            }
735            if let Some(key) = &self.test_key {
736                let key = key.expose_secret();
737                if !valid_testing_selector(key) {
738                    return Err(Error::Invalid("invalid testing application secret"));
739                }
740                req = req.header("x-testing-environment-key", key);
741            }
742        }
743        if is_mutation {
744            let mutation = self.mutation.clone().unwrap_or_default();
745            req = req.header("idempotency-key", mutation.key);
746            if let Some(version) = mutation.version {
747                req = req.header("if-match", format!("\"{version}\""));
748            }
749        }
750        if let Some(body) = body {
751            req = req.header("content-type", "application/json").body(body);
752        }
753        let mut response = req.send().await.map_err(transport)?;
754        if response
755            .headers()
756            .get("x-commit-api-version")
757            .is_some_and(|v| v != "1")
758        {
759            return Err(Error::Invalid(
760                "server selected an unsupported API contract",
761            ));
762        }
763        let status = response.status();
764        let request_id = response
765            .headers()
766            .get("x-request-id")
767            .and_then(|v| v.to_str().ok())
768            .map(str::to_owned);
769        const LIMIT: usize = 8 * 1024 * 1024;
770        if response.content_length().is_some_and(|n| n > LIMIT as u64) {
771            return Err(Error::ResponseTooLarge);
772        }
773        let mut bytes = Vec::new();
774        while let Some(chunk) = response.chunk().await.map_err(transport)? {
775            if bytes.len() + chunk.len() > LIMIT {
776                return Err(Error::ResponseTooLarge);
777            }
778            bytes.extend_from_slice(&chunk);
779        }
780        if !status.is_success() {
781            let parsed: Option<Value> = serde_json::from_slice(&bytes).ok();
782            let code = parsed
783                .as_ref()
784                .and_then(|v| v.pointer("/error/code"))
785                .and_then(Value::as_str)
786                .filter(|s| {
787                    s.len() <= 128 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
788                })
789                .unwrap_or("http_error")
790                .to_owned();
791            return Err(Error::Api {
792                status,
793                code,
794                request_id,
795            });
796        }
797        if bytes.is_empty() {
798            return Ok(Value::Null);
799        }
800        serde_json::from_slice(&bytes).map_err(Error::Decode)
801    }
802}
803fn transport(error: reqwest::Error) -> Error {
804    Error::Transport(error.without_url())
805}
806fn decode<T: DeserializeOwned>(value: Value) -> Result<T, Error> {
807    serde_json::from_value(value).map_err(Error::Decode)
808}
809
810fn valid_testing_selector(key: &str) -> bool {
811    (key.len() == 32 && key.bytes().all(|b| b.is_ascii_alphanumeric()))
812        || (key.len() == 47
813            && key.starts_with("ask_")
814            && key[4..]
815                .bytes()
816                .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'))
817}