Skip to main content

opencode_sdk_rs/
client.rs

1use std::{collections::HashMap, time::Duration};
2
3use http::{HeaderMap, header::HeaderValue};
4use serde::{Serialize, de::DeserializeOwned};
5
6use crate::{config::ClientOptions, error::OpencodeError, resources::app::AppResource};
7
8/// SDK version from `Cargo.toml`, used in the `User-Agent` header.
9const VERSION: &str = env!("CARGO_PKG_VERSION");
10
11/// Per-request option overrides.
12///
13/// All fields are optional; unset fields fall back to the client-level
14/// defaults configured via [`Opencode::builder`] or [`ClientOptions`].
15#[derive(Debug, Default, Clone)]
16pub struct RequestOptions {
17    /// Extra headers to send with this request only.
18    pub extra_headers: Option<HeaderMap>,
19    /// Override the per-request timeout.
20    pub timeout: Option<Duration>,
21    /// Override the maximum number of retries.
22    pub max_retries: Option<u32>,
23}
24
25/// The main `OpenCode` SDK client.
26///
27/// Holds connection settings and an inner HTTP client.  Construct via
28/// [`Opencode::new`], [`Opencode::with_options`], or [`Opencode::builder`].
29#[derive(Clone)]
30pub struct Opencode {
31    base_url: String,
32    timeout: Duration,
33    max_retries: u32,
34    default_headers: HeaderMap,
35    default_query: HashMap<String, String>,
36    pub(crate) http_client: hpx::Client,
37}
38
39impl std::fmt::Debug for Opencode {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("Opencode")
42            .field("base_url", &self.base_url)
43            .field("timeout", &self.timeout)
44            .field("max_retries", &self.max_retries)
45            .field("default_headers", &self.default_headers)
46            .field("default_query", &self.default_query)
47            .field("http_client", &"hpx::Client { .. }")
48            .finish()
49    }
50}
51
52impl Opencode {
53    /// Create a client with default configuration.
54    ///
55    /// Reads `OPENCODE_BASE_URL` from the environment; all other settings use
56    /// the JS SDK defaults (timeout = 60 s, `max_retries` = 2).
57    ///
58    /// # Errors
59    ///
60    /// Returns [`OpencodeError::Http`] if the underlying HTTP client cannot be
61    /// built (e.g. TLS back-end init failure).
62    pub fn new() -> Result<Self, OpencodeError> {
63        Self::with_options(&ClientOptions::default())
64    }
65
66    /// Create a client from explicit [`ClientOptions`].
67    ///
68    /// # Errors
69    ///
70    /// Returns [`OpencodeError::Http`] if the underlying HTTP client cannot be
71    /// built.
72    pub fn with_options(opts: &ClientOptions) -> Result<Self, OpencodeError> {
73        let timeout = opts.resolve_timeout();
74        let default_headers = opts.resolve_default_headers();
75
76        let http_client = hpx::Client::builder()
77            .timeout(timeout)
78            .default_headers(default_headers.clone())
79            .build()
80            .map_err(|e| OpencodeError::Http(Box::new(e)))?;
81
82        Ok(Self {
83            base_url: opts.resolve_base_url().to_owned(),
84            timeout,
85            max_retries: opts.resolve_max_retries(),
86            default_headers,
87            default_query: opts.resolve_default_query(),
88            http_client,
89        })
90    }
91
92    /// Return an [`OpencodeBuilder`] for fluent configuration.
93    #[must_use]
94    pub fn builder() -> OpencodeBuilder {
95        OpencodeBuilder { options: ClientOptions::default() }
96    }
97
98    // ── Getters ────────────────────────────────────────────────────
99
100    /// The resolved base URL.
101    #[must_use]
102    pub fn base_url(&self) -> &str {
103        &self.base_url
104    }
105
106    /// The per-request timeout.
107    #[must_use]
108    pub const fn timeout(&self) -> Duration {
109        self.timeout
110    }
111
112    /// Maximum automatic retries.
113    #[must_use]
114    pub const fn max_retries(&self) -> u32 {
115        self.max_retries
116    }
117
118    /// Default headers sent with every request.
119    #[must_use]
120    pub const fn default_headers(&self) -> &HeaderMap {
121        &self.default_headers
122    }
123
124    /// Default query parameters appended to every request.
125    #[must_use]
126    pub const fn default_query(&self) -> &HashMap<String, String> {
127        &self.default_query
128    }
129
130    // ── Resource accessors ─────────────────────────────────────
131
132    /// Access the App resource.
133    pub const fn app(&self) -> AppResource<'_> {
134        AppResource::new(self)
135    }
136
137    /// Access the Config resource.
138    pub const fn config(&self) -> crate::resources::config::ConfigResource<'_> {
139        crate::resources::config::ConfigResource::new(self)
140    }
141
142    /// Access the Event resource.
143    pub const fn event(&self) -> crate::resources::event::EventResource<'_> {
144        crate::resources::event::EventResource::new(self)
145    }
146
147    /// Access the File resource.
148    pub const fn file(&self) -> crate::resources::file::FileResource<'_> {
149        crate::resources::file::FileResource::new(self)
150    }
151
152    /// Access the Find resource.
153    pub const fn find(&self) -> crate::resources::find::FindResource<'_> {
154        crate::resources::find::FindResource::new(self)
155    }
156
157    /// Access the Session resource.
158    pub const fn session(&self) -> crate::resources::session::SessionResource<'_> {
159        crate::resources::session::SessionResource::new(self)
160    }
161
162    /// Access the Tui resource.
163    pub const fn tui(&self) -> crate::resources::tui::TuiResource<'_> {
164        crate::resources::tui::TuiResource::new(self)
165    }
166
167    // ── URL & Header Building ──────────────────────────────────
168
169    /// Build a full URL by joining `base_url` + `path`, then appending
170    /// `default_query` and any extra `query` parameters.
171    ///
172    /// Query keys are sorted for deterministic output.
173    pub(crate) fn build_url(&self, path: &str, query: Option<&HashMap<String, String>>) -> String {
174        let base = self.base_url.trim_end_matches('/');
175        let path_part = if path.starts_with('/') { path.to_owned() } else { format!("/{path}") };
176
177        let mut params: Vec<(&str, &str)> =
178            self.default_query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
179
180        if let Some(q) = query {
181            params.extend(q.iter().map(|(k, v)| (k.as_str(), v.as_str())));
182        }
183
184        if params.is_empty() {
185            format!("{base}{path_part}")
186        } else {
187            params.sort_by_key(|(k, _)| *k);
188            let qs = params.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&");
189            format!("{base}{path_part}?{qs}")
190        }
191    }
192
193    /// Build request headers: default headers + `Accept` + `User-Agent` +
194    /// extras.
195    ///
196    /// Includes `x-retry-count` when `retry_count > 0`.
197    pub(crate) fn build_headers(
198        &self,
199        extra_headers: Option<&HeaderMap>,
200        retry_count: u32,
201    ) -> HeaderMap {
202        let mut headers = self.default_headers.clone();
203
204        headers.insert(http::header::ACCEPT, HeaderValue::from_static("application/json"));
205
206        if let Ok(ua) = HeaderValue::from_str(&format!("opencode-sdk-rs/{VERSION}")) {
207            headers.insert(http::header::USER_AGENT, ua);
208        }
209
210        if retry_count > 0 &&
211            let Ok(val) = HeaderValue::from_str(&retry_count.to_string())
212        {
213            headers.insert("x-retry-count", val);
214        }
215
216        if let Some(extra) = extra_headers {
217            for (key, value) in extra {
218                headers.insert(key, value.clone());
219            }
220        }
221
222        headers
223    }
224
225    // ── Internal request engine ────────────────────────────────
226
227    /// Send an HTTP request with automatic retries and error mapping.
228    ///
229    /// The caller supplies a pre-serialised `body` (as [`serde_json::Value`])
230    /// and an optional serialisable `query` struct.  On success the JSON
231    /// response is deserialised into `T`.
232    async fn make_request<T, Q>(
233        &self,
234        method: http::Method,
235        path: &str,
236        body: Option<serde_json::Value>,
237        query: Option<&Q>,
238        options: Option<&RequestOptions>,
239    ) -> Result<T, OpencodeError>
240    where
241        T: DeserializeOwned,
242        Q: Serialize + Sync + ?Sized,
243    {
244        let url = self.build_url(path, None);
245        let max_retries = options.and_then(|o| o.max_retries).unwrap_or(self.max_retries);
246        let timeout = options.and_then(|o| o.timeout).unwrap_or(self.timeout);
247        let extra_headers = options.and_then(|o| o.extra_headers.as_ref());
248
249        let mut last_error: Option<OpencodeError> = None;
250
251        for attempt in 0..=max_retries {
252            let headers = self.build_headers(extra_headers, attempt);
253
254            tracing::debug!(
255                method = %method,
256                url = %url,
257                attempt,
258                "sending request"
259            );
260
261            let mut req =
262                self.http_client.request(method.clone(), &url).headers(headers).timeout(timeout);
263
264            if let Some(q) = query {
265                req = req.query(q);
266            }
267
268            if let Some(ref b) = body {
269                req = req.json(b);
270            }
271
272            let result = req.send().await;
273
274            match result {
275                Ok(resp) => {
276                    let status = resp.status();
277                    let resp_headers = resp.headers().clone();
278
279                    if status.is_success() {
280                        let bytes =
281                            resp.bytes().await.map_err(|e| OpencodeError::Http(Box::new(e)))?;
282                        let parsed: T = serde_json::from_slice(&bytes)?;
283                        return Ok(parsed);
284                    }
285
286                    // Error response — read body then decide to retry or fail.
287                    let body_bytes = resp.bytes().await.ok();
288                    let body_value: Option<serde_json::Value> =
289                        body_bytes.as_ref().and_then(|b| serde_json::from_slice(b).ok());
290
291                    let err = OpencodeError::from_response(
292                        status.as_u16(),
293                        Some(resp_headers.clone()),
294                        body_value,
295                    );
296
297                    if attempt < max_retries && should_retry(&err, &resp_headers) {
298                        let delay = retry_delay(attempt, &resp_headers);
299                        tracing::debug!(
300                            attempt,
301                            delay_ms = delay.as_millis() as u64,
302                            "retrying after error"
303                        );
304                        tokio::time::sleep(delay).await;
305                        last_error = Some(err);
306                        continue;
307                    }
308
309                    return Err(err);
310                }
311                Err(send_err) => {
312                    let err = classify_transport_error(send_err);
313
314                    if attempt < max_retries && err.is_retryable() {
315                        let delay = retry_delay(attempt, &HeaderMap::new());
316                        tracing::debug!(
317                            attempt,
318                            delay_ms = delay.as_millis() as u64,
319                            "retrying after transport error"
320                        );
321                        tokio::time::sleep(delay).await;
322                        last_error = Some(err);
323                        continue;
324                    }
325
326                    return Err(err);
327                }
328            }
329        }
330
331        // Should be unreachable given the loop guarantees, but handle
332        // gracefully.
333        Err(last_error
334            .unwrap_or_else(|| OpencodeError::Http("max retries exhausted".to_owned().into())))
335    }
336
337    // ── Public convenience methods ─────────────────────────────
338
339    /// Send a `GET` request and deserialise the JSON response.
340    pub async fn get<T: DeserializeOwned>(
341        &self,
342        path: &str,
343        options: Option<&RequestOptions>,
344    ) -> Result<T, OpencodeError> {
345        self.make_request::<T, ()>(http::Method::GET, path, None, None, options).await
346    }
347
348    /// Send a `GET` request with query parameters.
349    pub async fn get_with_query<T, Q>(
350        &self,
351        path: &str,
352        query: Option<&Q>,
353        options: Option<&RequestOptions>,
354    ) -> Result<T, OpencodeError>
355    where
356        T: DeserializeOwned,
357        Q: Serialize + Sync + ?Sized,
358    {
359        self.make_request(http::Method::GET, path, None, query, options).await
360    }
361
362    /// Send a `POST` request with an optional JSON body.
363    pub async fn post<T, B>(
364        &self,
365        path: &str,
366        body: Option<&B>,
367        options: Option<&RequestOptions>,
368    ) -> Result<T, OpencodeError>
369    where
370        T: DeserializeOwned,
371        B: Serialize + Sync,
372    {
373        let body_value = body.map(serde_json::to_value).transpose()?;
374        self.make_request::<T, ()>(http::Method::POST, path, body_value, None, options).await
375    }
376
377    /// Send a `PUT` request with an optional JSON body.
378    pub async fn put<T, B>(
379        &self,
380        path: &str,
381        body: Option<&B>,
382        options: Option<&RequestOptions>,
383    ) -> Result<T, OpencodeError>
384    where
385        T: DeserializeOwned,
386        B: Serialize + Sync,
387    {
388        let body_value = body.map(serde_json::to_value).transpose()?;
389        self.make_request::<T, ()>(http::Method::PUT, path, body_value, None, options).await
390    }
391
392    /// Send a `PATCH` request with an optional JSON body.
393    pub async fn patch<T, B>(
394        &self,
395        path: &str,
396        body: Option<&B>,
397        options: Option<&RequestOptions>,
398    ) -> Result<T, OpencodeError>
399    where
400        T: DeserializeOwned,
401        B: Serialize + Sync,
402    {
403        let body_value = body.map(serde_json::to_value).transpose()?;
404        self.make_request::<T, ()>(http::Method::PATCH, path, body_value, None, options).await
405    }
406
407    /// Send a GET request and return a streaming SSE response.
408    ///
409    /// Unlike other HTTP methods, this does NOT parse the full response body.
410    /// Instead it returns an [`crate::SseStream`] that lazily decodes each SSE
411    /// event's `data` field as JSON of type `T`.
412    pub async fn get_stream<T: DeserializeOwned + 'static>(
413        &self,
414        path: &str,
415    ) -> Result<crate::streaming::SseStream<T>, OpencodeError> {
416        let url = self.build_url(path, None);
417        let headers = self.build_headers(None, 0);
418
419        let response = self
420            .http_client
421            .get(&url)
422            .headers(headers)
423            .send()
424            .await
425            .map_err(classify_transport_error)?;
426
427        let status = response.status();
428        if !status.is_success() {
429            let resp_headers = response.headers().clone();
430            let body_bytes = response.bytes().await.ok();
431            let body_value: Option<serde_json::Value> =
432                body_bytes.as_ref().and_then(|b| serde_json::from_slice(b).ok());
433            return Err(OpencodeError::from_response(
434                status.as_u16(),
435                Some(resp_headers),
436                body_value,
437            ));
438        }
439
440        Ok(crate::streaming::SseStream::new(response.bytes_stream()))
441    }
442
443    /// Send a `DELETE` request with an optional JSON body.
444    pub async fn delete<T, B>(
445        &self,
446        path: &str,
447        body: Option<&B>,
448        options: Option<&RequestOptions>,
449    ) -> Result<T, OpencodeError>
450    where
451        T: DeserializeOwned,
452        B: Serialize + Sync,
453    {
454        let body_value = body.map(serde_json::to_value).transpose()?;
455        self.make_request::<T, ()>(http::Method::DELETE, path, body_value, None, options).await
456    }
457}
458
459// ── Free helper functions ──────────────────────────────────────────
460
461/// Decide whether a request should be retried, honouring `x-should-retry`.
462fn should_retry(err: &OpencodeError, headers: &HeaderMap) -> bool {
463    if let Some(val) = headers.get("x-should-retry") &&
464        let Ok(s) = val.to_str()
465    {
466        match s {
467            "true" => return true,
468            "false" => return false,
469            _ => {}
470        }
471    }
472    err.is_retryable()
473}
474
475/// Calculate the retry delay from response headers or exponential backoff.
476///
477/// Checks `retry-after-ms`, then `retry-after` (seconds), then falls back
478/// to `min(0.5 * 2^attempt, 8) * jitter`.
479fn retry_delay(attempt: u32, headers: &HeaderMap) -> Duration {
480    // Prefer explicit server hints.
481    if let Some(ms) = header_u64(headers, "retry-after-ms") {
482        return Duration::from_millis(ms);
483    }
484
485    if let Some(val) = headers.get("retry-after") &&
486        let Ok(s) = val.to_str() &&
487        let Ok(secs) = s.parse::<f64>()
488    {
489        return Duration::from_secs_f64(secs);
490    }
491
492    // Exponential back-off with jitter.
493    let base = (0.5 * 2.0_f64.powi(attempt.cast_signed())).min(8.0);
494    Duration::from_secs_f64(base * jitter_factor())
495}
496
497/// Try to parse a header value as `u64`.
498fn header_u64(headers: &HeaderMap, name: &str) -> Option<u64> {
499    headers.get(name)?.to_str().ok()?.parse().ok()
500}
501
502/// Generate a jitter factor in `[0.75, 1.0)` using system-clock entropy.
503fn jitter_factor() -> f64 {
504    let nanos = std::time::SystemTime::now()
505        .duration_since(std::time::UNIX_EPOCH)
506        .unwrap_or_default()
507        .subsec_nanos();
508    (f64::from(nanos % 1000) / 1000.0).mul_add(-0.25, 1.0)
509}
510
511/// Map an `hpx` transport error to the appropriate [`OpencodeError`] variant.
512fn classify_transport_error(err: hpx::Error) -> OpencodeError {
513    if err.is_timeout() {
514        OpencodeError::Timeout
515    } else if err.is_connect() {
516        OpencodeError::Connection { message: err.to_string(), source: Some(Box::new(err)) }
517    } else {
518        OpencodeError::Http(Box::new(err))
519    }
520}
521
522/// Fluent builder for [`Opencode`].
523#[derive(Debug)]
524pub struct OpencodeBuilder {
525    options: ClientOptions,
526}
527
528impl OpencodeBuilder {
529    /// Override the base URL.
530    #[must_use]
531    pub fn base_url(mut self, url: impl Into<String>) -> Self {
532        self.options.base_url = Some(url.into());
533        self
534    }
535
536    /// Override the per-request timeout.
537    #[must_use]
538    pub const fn timeout(mut self, timeout: Duration) -> Self {
539        self.options.timeout = Some(timeout);
540        self
541    }
542
543    /// Override the maximum number of retries.
544    #[must_use]
545    pub const fn max_retries(mut self, retries: u32) -> Self {
546        self.options.max_retries = Some(retries);
547        self
548    }
549
550    /// Set default headers for every request.
551    #[must_use]
552    pub fn default_headers(mut self, headers: HeaderMap) -> Self {
553        self.options.default_headers = Some(headers);
554        self
555    }
556
557    /// Set default query parameters for every request.
558    #[must_use]
559    pub fn default_query(mut self, query: HashMap<String, String>) -> Self {
560        self.options.default_query = Some(query);
561        self
562    }
563
564    /// Build the [`Opencode`] client.
565    ///
566    /// # Errors
567    ///
568    /// Returns [`OpencodeError::Http`] if the underlying HTTP client cannot be
569    /// built.
570    pub fn build(self) -> Result<Opencode, OpencodeError> {
571        Opencode::with_options(&self.options)
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use crate::config::{DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT};
579
580    // ── Helper ─────────────────────────────────────────────────
581
582    /// Build a client with known defaults and no env-var side-effects.
583    fn test_client() -> Opencode {
584        Opencode::with_options(&ClientOptions::empty()).expect("test client")
585    }
586
587    fn test_client_with_defaults(
588        base: &str,
589        dq: HashMap<String, String>,
590        dh: HeaderMap,
591    ) -> Opencode {
592        Opencode::with_options(&ClientOptions {
593            base_url: Some(base.to_owned()),
594            timeout: None,
595            max_retries: None,
596            default_headers: Some(dh),
597            default_query: Some(dq),
598        })
599        .expect("test client")
600    }
601
602    // ── Constructor / builder tests (existing) ─────────────────
603
604    #[test]
605    fn with_empty_options_uses_defaults() {
606        let client = Opencode::with_options(&ClientOptions::empty()).expect("client");
607        assert_eq!(client.base_url(), DEFAULT_BASE_URL);
608        assert_eq!(client.timeout(), DEFAULT_TIMEOUT);
609        assert_eq!(client.max_retries(), DEFAULT_MAX_RETRIES);
610        assert!(client.default_headers().is_empty());
611        assert!(client.default_query().is_empty());
612    }
613
614    #[test]
615    fn with_options_custom() {
616        let opts = ClientOptions {
617            base_url: Some("http://myhost:8080".to_owned()),
618            timeout: Some(Duration::from_secs(10)),
619            max_retries: Some(5),
620            default_headers: None,
621            default_query: None,
622        };
623        let client = Opencode::with_options(&opts).expect("client");
624        assert_eq!(client.base_url(), "http://myhost:8080");
625        assert_eq!(client.timeout(), Duration::from_secs(10));
626        assert_eq!(client.max_retries(), 5);
627    }
628
629    #[test]
630    fn builder_overrides() {
631        let client = Opencode::builder()
632            .base_url("http://builder:1234")
633            .timeout(Duration::from_secs(15))
634            .max_retries(0)
635            .build()
636            .expect("client");
637
638        assert_eq!(client.base_url(), "http://builder:1234");
639        assert_eq!(client.timeout(), Duration::from_secs(15));
640        assert_eq!(client.max_retries(), 0);
641    }
642
643    #[test]
644    fn builder_with_explicit_empty_falls_back() {
645        let client = Opencode::with_options(&ClientOptions::empty()).expect("client");
646        assert_eq!(client.base_url(), DEFAULT_BASE_URL);
647        assert_eq!(client.timeout(), DEFAULT_TIMEOUT);
648        assert_eq!(client.max_retries(), DEFAULT_MAX_RETRIES);
649    }
650
651    #[test]
652    fn builder_base_url_overrides_option() {
653        let client = Opencode::builder().base_url("http://explicit:2222").build().expect("client");
654        assert_eq!(client.base_url(), "http://explicit:2222");
655    }
656
657    // ── build_url tests ────────────────────────────────────────
658
659    #[test]
660    fn build_url_simple_path() {
661        let client = test_client();
662        let url = client.build_url("/app", None);
663        assert_eq!(url, format!("{DEFAULT_BASE_URL}/app"));
664    }
665
666    #[test]
667    fn build_url_strips_trailing_slash_from_base() {
668        let client =
669            test_client_with_defaults("http://example.com/", HashMap::new(), HeaderMap::new());
670        assert_eq!(client.build_url("/path", None), "http://example.com/path");
671    }
672
673    #[test]
674    fn build_url_adds_leading_slash() {
675        let client = test_client();
676        let url = client.build_url("session", None);
677        assert_eq!(url, format!("{DEFAULT_BASE_URL}/session"));
678    }
679
680    #[test]
681    fn build_url_with_default_query() {
682        let mut dq = HashMap::new();
683        dq.insert("version".to_owned(), "2".to_owned());
684        let client = test_client_with_defaults("http://host", dq, HeaderMap::new());
685
686        let url = client.build_url("/api", None);
687        assert_eq!(url, "http://host/api?version=2");
688    }
689
690    #[test]
691    fn build_url_with_extra_query() {
692        let client = test_client_with_defaults("http://host", HashMap::new(), HeaderMap::new());
693
694        let mut extra = HashMap::new();
695        extra.insert("foo".to_owned(), "bar".to_owned());
696
697        let url = client.build_url("/api", Some(&extra));
698        assert_eq!(url, "http://host/api?foo=bar");
699    }
700
701    #[test]
702    fn build_url_merges_default_and_extra_query() {
703        let mut dq = HashMap::new();
704        dq.insert("a".to_owned(), "1".to_owned());
705
706        let client = test_client_with_defaults("http://host", dq, HeaderMap::new());
707
708        let mut extra = HashMap::new();
709        extra.insert("b".to_owned(), "2".to_owned());
710
711        let url = client.build_url("/x", Some(&extra));
712        // Sorted by key
713        assert_eq!(url, "http://host/x?a=1&b=2");
714    }
715
716    #[test]
717    fn build_url_no_query_no_question_mark() {
718        let client = test_client();
719        let url = client.build_url("/clean", None);
720        assert!(!url.contains('?'));
721    }
722
723    // ── build_headers tests ────────────────────────────────────
724
725    #[test]
726    fn build_headers_sets_accept_json() {
727        let client = test_client();
728        let headers = client.build_headers(None, 0);
729        assert_eq!(
730            headers.get(http::header::ACCEPT).map(|v| v.to_str().ok()),
731            Some(Some("application/json"))
732        );
733    }
734
735    #[test]
736    fn build_headers_sets_user_agent() {
737        let client = test_client();
738        let headers = client.build_headers(None, 0);
739        let ua =
740            headers.get(http::header::USER_AGENT).expect("user-agent").to_str().expect("ascii");
741        assert!(ua.starts_with("opencode-sdk-rs/"), "unexpected user-agent: {ua}");
742    }
743
744    #[test]
745    fn build_headers_no_retry_count_on_first_attempt() {
746        let client = test_client();
747        let headers = client.build_headers(None, 0);
748        assert!(headers.get("x-retry-count").is_none());
749    }
750
751    #[test]
752    fn build_headers_includes_retry_count() {
753        let client = test_client();
754        let headers = client.build_headers(None, 3);
755        assert_eq!(headers.get("x-retry-count").map(|v| v.to_str().ok()), Some(Some("3")));
756    }
757
758    #[test]
759    fn build_headers_merges_extra() {
760        let client = test_client();
761        let mut extra = HeaderMap::new();
762        extra.insert("x-custom", HeaderValue::from_static("yes"));
763
764        let headers = client.build_headers(Some(&extra), 0);
765        assert_eq!(headers.get("x-custom").map(|v| v.to_str().ok()), Some(Some("yes")));
766        // Standard headers still present
767        assert!(headers.get(http::header::ACCEPT).is_some());
768    }
769
770    #[test]
771    fn build_headers_includes_default_headers() {
772        let mut dh = HeaderMap::new();
773        dh.insert("x-default", HeaderValue::from_static("value"));
774
775        let client = test_client_with_defaults(DEFAULT_BASE_URL, HashMap::new(), dh);
776        let headers = client.build_headers(None, 0);
777        assert_eq!(headers.get("x-default").map(|v| v.to_str().ok()), Some(Some("value")));
778    }
779
780    #[test]
781    fn build_headers_extra_overrides_default() {
782        let mut dh = HeaderMap::new();
783        dh.insert("x-key", HeaderValue::from_static("default"));
784
785        let client = test_client_with_defaults(DEFAULT_BASE_URL, HashMap::new(), dh);
786
787        let mut extra = HeaderMap::new();
788        extra.insert("x-key", HeaderValue::from_static("override"));
789
790        let headers = client.build_headers(Some(&extra), 0);
791        assert_eq!(headers.get("x-key").map(|v| v.to_str().ok()), Some(Some("override")));
792    }
793
794    // ── should_retry tests ─────────────────────────────────────
795
796    #[test]
797    fn should_retry_honours_x_should_retry_true() {
798        let err = OpencodeError::bad_request(None, None, "nope");
799        let mut headers = HeaderMap::new();
800        headers.insert("x-should-retry", HeaderValue::from_static("true"));
801        assert!(should_retry(&err, &headers));
802    }
803
804    #[test]
805    fn should_retry_honours_x_should_retry_false() {
806        let err = OpencodeError::internal_server(500, None, None, "fail");
807        let mut headers = HeaderMap::new();
808        headers.insert("x-should-retry", HeaderValue::from_static("false"));
809        assert!(!should_retry(&err, &headers));
810    }
811
812    #[test]
813    fn should_retry_falls_back_to_is_retryable() {
814        let retryable = OpencodeError::rate_limit(None, None, "slow down");
815        assert!(should_retry(&retryable, &HeaderMap::new()));
816
817        let not_retryable = OpencodeError::not_found(None, None, "gone");
818        assert!(!should_retry(&not_retryable, &HeaderMap::new()));
819    }
820
821    // ── retry_delay tests ──────────────────────────────────────
822
823    #[test]
824    fn retry_delay_uses_retry_after_ms() {
825        let mut headers = HeaderMap::new();
826        headers.insert("retry-after-ms", HeaderValue::from_static("1500"));
827        let delay = retry_delay(0, &headers);
828        assert_eq!(delay, Duration::from_millis(1500));
829    }
830
831    #[test]
832    fn retry_delay_uses_retry_after_seconds() {
833        let mut headers = HeaderMap::new();
834        headers.insert("retry-after", HeaderValue::from_static("2"));
835        let delay = retry_delay(0, &headers);
836        assert_eq!(delay, Duration::from_secs(2));
837    }
838
839    #[test]
840    fn retry_delay_exponential_backoff_attempt_0() {
841        // base = min(0.5 * 2^0, 8) = 0.5 → * jitter [0.75, 1.0)
842        let delay = retry_delay(0, &HeaderMap::new());
843        let secs = delay.as_secs_f64();
844        assert!((0.375..=0.5).contains(&secs), "attempt 0 delay {secs}s out of range");
845    }
846
847    #[test]
848    fn retry_delay_exponential_backoff_attempt_4() {
849        // base = min(0.5 * 2^4, 8) = min(8, 8) = 8 → * jitter [0.75, 1.0)
850        let delay = retry_delay(4, &HeaderMap::new());
851        let secs = delay.as_secs_f64();
852        assert!((6.0..=8.0).contains(&secs), "attempt 4 delay {secs}s out of range");
853    }
854
855    #[test]
856    fn retry_delay_caps_at_8_seconds() {
857        // base = min(0.5 * 2^10, 8) = 8 → * jitter
858        let delay = retry_delay(10, &HeaderMap::new());
859        let secs = delay.as_secs_f64();
860        assert!(secs <= 8.0, "delay {secs}s should be capped at 8");
861    }
862
863    // ── jitter_factor tests ────────────────────────────────────
864
865    #[test]
866    fn jitter_factor_in_range() {
867        for _ in 0..100 {
868            let j = jitter_factor();
869            assert!((0.75..=1.0).contains(&j), "jitter {j} out of [0.75, 1.0]");
870        }
871    }
872
873    // ── RequestOptions defaults ────────────────────────────────
874
875    #[test]
876    fn request_options_default_is_all_none() {
877        let opts = RequestOptions::default();
878        assert!(opts.extra_headers.is_none());
879        assert!(opts.timeout.is_none());
880        assert!(opts.max_retries.is_none());
881    }
882}