Skip to main content

writ_client/
cloud.rs

1//! [`CloudClient`] — the tiered **Writ Cloud** surface: `scrape`, `map`, and
2//! whole-site `crawl`.
3//!
4//! Unlike the rest of this SDK (which talks to the LOCAL daemon), these verbs run
5//! on Writ Cloud — never on the calling machine — with a Firecrawl-style tier
6//! model resolved from your credential:
7//!
8//! - **Metered** — an API key (builder `api_key` → `WRIT_API_KEY` env) → the
9//!   authed `/api/crawl/*` surface, billed per page. `scrape`, `map`, AND `crawl`
10//!   all work.
11//! - **Keyless** — no key → the free `/v1/keyless/*` tier, daily-capped per
12//!   install (a stable client-id header) AND per IP. `scrape` + `map` only;
13//!   `crawl` returns [`WritError::ApiKeyRequired`] before any network call.
14//!
15//! The credential fallback chain (`api_key` arg → `WRIT_API_KEY` → keyless)
16//! mirrors Firecrawl's, so the same code scales from an anonymous test to a
17//! metered production key with no branching at the call site.
18//!
19//! ```no_run
20//! # async fn demo() -> Result<(), writ_client::WritError> {
21//! use writ_client::CloudClient;
22//!
23//! let cloud = CloudClient::from_env()?;      // metered if WRIT_API_KEY is set, else keyless
24//! let page = cloud.scrape("https://example.com").await?;
25//! println!("[{}] {}", cloud.tier(), page.markdown);
26//! # Ok(())
27//! # }
28//! ```
29
30use std::path::PathBuf;
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::{Arc, OnceLock};
33use std::time::Duration;
34
35use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
36use reqwest::Method;
37use serde_json::{Map, Value};
38
39use crate::client::USER_AGENT;
40use crate::discovery::env_var;
41use crate::error::{code_for_status, Result, WritError};
42use crate::models::{CrawlJob, CrawlStartParams};
43
44/// Default Writ Cloud base URL.
45const DEFAULT_CLOUD_URL: &str = "https://api.usewrit.app";
46
47/// Keyless device-identity header.
48const CLIENT_ID_HEADER: &str = "X-Writ-Client-Id";
49
50/// Default per-request timeout (mirrors the daemon client).
51const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
52
53/// Which access tier a [`CloudClient`] resolved to.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum CloudTier {
56    /// No API key — the free, daily-capped `/v1/keyless/*` surface.
57    Keyless,
58    /// An API key is present — the authed, per-page-billed `/api/crawl/*` surface.
59    Metered,
60}
61
62impl CloudTier {
63    /// The wire string for this tier: `"keyless"` or `"metered"` (identical
64    /// across every Writ SDK).
65    pub fn as_str(&self) -> &'static str {
66        match self {
67            CloudTier::Keyless => "keyless",
68            CloudTier::Metered => "metered",
69        }
70    }
71}
72
73impl std::fmt::Display for CloudTier {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.write_str(self.as_str())
76    }
77}
78
79/// Remaining keyless allowance echoed back on keyless calls.
80#[derive(Debug, Clone)]
81pub struct KeylessQuota {
82    /// Always [`CloudTier::Keyless`].
83    pub tier: CloudTier,
84    /// Keyless requests left in the current window.
85    pub requests_remaining: i64,
86    /// Keyless pages left in the current window.
87    pub pages_remaining: i64,
88    /// Daily request allowance.
89    pub requests_per_day: i64,
90    /// Daily page allowance.
91    pub pages_per_day: i64,
92    /// ISO timestamp when the allowance refills.
93    pub reset_at: String,
94    /// Where to upgrade for a metered quota, if the server reported it.
95    pub upgrade_url: Option<String>,
96}
97
98/// One clean-markdown page ([`CloudClient::scrape`]).
99#[derive(Debug, Clone)]
100pub struct ScrapeResult {
101    /// The scraped URL.
102    pub url: String,
103    /// Page title, if any.
104    pub title: Option<String>,
105    /// Output format (usually `"markdown"`).
106    pub format: String,
107    /// The extracted markdown.
108    pub markdown: String,
109    /// Per-block element counts the server reported.
110    pub counts: Map<String, Value>,
111    /// The tier this call resolved to.
112    pub tier: CloudTier,
113    /// Present on the keyless tier only — remaining daily allowance.
114    pub quota: Option<KeylessQuota>,
115}
116
117/// One ranked URL in a [`MapResult`].
118#[derive(Debug, Clone)]
119pub struct MapEntry {
120    /// The discovered URL.
121    pub url: String,
122    /// Relevance score for the optional `search` (0 when none).
123    pub score: f64,
124    /// Link/anchor title, if any.
125    pub title: Option<String>,
126}
127
128/// `returned` / `total` counts on a [`MapResult`].
129#[derive(Debug, Clone, Default)]
130pub struct MapCounts {
131    /// URLs returned in this response.
132    pub returned: i64,
133    /// Total URLs discovered.
134    pub total: i64,
135}
136
137/// A site's URLs, ranked by an optional `search` ([`CloudClient::map`]).
138#[derive(Debug, Clone)]
139pub struct MapResult {
140    /// The mapped seed URL.
141    pub url: String,
142    /// The resolved host, if the server reported it.
143    pub host: Option<String>,
144    /// Ranked URLs.
145    pub urls: Vec<MapEntry>,
146    /// Returned / total counts.
147    pub counts: MapCounts,
148    /// The tier this call resolved to.
149    pub tier: CloudTier,
150    /// Present on the keyless tier only — remaining daily allowance.
151    pub quota: Option<KeylessQuota>,
152}
153
154/// Options for [`CloudClient::map`].
155#[derive(Debug, Clone, Default)]
156pub struct MapOptions {
157    /// Rank the discovered URLs by relevance to this query (empty = no ranking).
158    pub search: Option<String>,
159    /// Cap the number of URLs returned.
160    pub limit: Option<i64>,
161}
162
163/// Configuration for [`CloudClient`]. `build()` performs **no network I/O**; the
164/// only side effect is reading/minting `~/.writ/client_id` on the first keyless
165/// call (lazily), never at construction.
166#[derive(Debug, Default, Clone)]
167pub struct CloudClientBuilder {
168    api_key: Option<String>,
169    cloud_url: Option<String>,
170    client_id: Option<String>,
171    timeout: Option<Duration>,
172}
173
174impl CloudClientBuilder {
175    /// Metered API key (`wt_`/`wlk_`). Falls back to `WRIT_API_KEY`; absent ⇒
176    /// keyless.
177    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
178        self.api_key = Some(api_key.into());
179        self
180    }
181
182    /// Cloud base URL. Falls back to `WRIT_CLOUD_URL`, then
183    /// `https://api.usewrit.app`. A trailing `/` is stripped.
184    pub fn cloud_url(mut self, cloud_url: impl Into<String>) -> Self {
185        self.cloud_url = Some(cloud_url.into());
186        self
187    }
188
189    /// Override the keyless device/client id (else read/mint `~/.writ/client_id`).
190    pub fn client_id(mut self, client_id: impl Into<String>) -> Self {
191        self.client_id = Some(client_id.into());
192        self
193    }
194
195    /// Per-request timeout (default 30 s).
196    pub fn timeout(mut self, timeout: Duration) -> Self {
197        self.timeout = Some(timeout);
198        self
199    }
200
201    /// Build the client, resolving each field from its explicit value, then the
202    /// matching env var, then the documented default. No network I/O.
203    pub fn build(self) -> Result<CloudClient> {
204        let api_key = self.api_key.or_else(|| env_var("WRIT_API_KEY"));
205        let base = self
206            .cloud_url
207            .or_else(|| env_var("WRIT_CLOUD_URL"))
208            .unwrap_or_else(|| DEFAULT_CLOUD_URL.to_string())
209            .trim_end_matches('/')
210            .to_string();
211        let client_id_override = self.client_id.or_else(|| env_var("WRIT_CLIENT_ID"));
212
213        let http = reqwest::Client::builder()
214            .timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
215            .user_agent(USER_AGENT)
216            .build()
217            .map_err(|e| WritError::Connection(format!("building cloud http client: {e}")))?;
218
219        Ok(CloudClient {
220            inner: Arc::new(CloudInner {
221                api_key,
222                base,
223                client_id_override,
224                client_id_cache: OnceLock::new(),
225                http,
226            }),
227        })
228    }
229}
230
231/// The async client for the tiered **Writ Cloud** surface (see the module docs).
232///
233/// Construct with [`CloudClient::builder`] (explicit config) or
234/// [`CloudClient::from_env`] (pure env resolution). The credential decides the
235/// tier: an API key ⇒ metered, none ⇒ keyless.
236#[derive(Debug, Clone)]
237pub struct CloudClient {
238    inner: Arc<CloudInner>,
239}
240
241#[derive(Debug)]
242struct CloudInner {
243    api_key: Option<String>,
244    base: String,
245    client_id_override: Option<String>,
246    client_id_cache: OnceLock<String>,
247    http: reqwest::Client,
248}
249
250impl CloudClient {
251    /// Start explicit configuration.
252    pub fn builder() -> CloudClientBuilder {
253        CloudClientBuilder::default()
254    }
255
256    /// Build a client purely from the environment (`WRIT_API_KEY`,
257    /// `WRIT_CLOUD_URL`, `WRIT_CLIENT_ID`) and the defaults.
258    pub fn from_env() -> Result<CloudClient> {
259        CloudClientBuilder::default().build()
260    }
261
262    /// The resolved cloud base URL (no trailing slash).
263    pub fn base_url(&self) -> &str {
264        &self.inner.base
265    }
266
267    /// The tier this client will use: [`CloudTier::Metered`] when an API key is
268    /// present, else [`CloudTier::Keyless`].
269    pub fn tier(&self) -> CloudTier {
270        if self.inner.api_key.is_some() {
271            CloudTier::Metered
272        } else {
273            CloudTier::Keyless
274        }
275    }
276
277    /// Scrape ONE page to clean markdown. Works on both tiers.
278    ///
279    /// `POST /api/crawl/scrape` (metered) or `/v1/keyless/scrape` (keyless), body
280    /// `{"url": url}`.
281    pub async fn scrape(&self, url: &str) -> Result<ScrapeResult> {
282        let path = if self.inner.api_key.is_some() {
283            "/api/crawl/scrape"
284        } else {
285            "/v1/keyless/scrape"
286        };
287        let raw = self
288            .send(Method::POST, path, Some(&serde_json::json!({ "url": url })))
289            .await?;
290        Ok(normalize_scrape(&raw, self.tier()))
291    }
292
293    /// Map a site's URLs, ranked by an optional `search`. Works on both tiers.
294    ///
295    /// `POST /api/crawl/map` (metered) or `/v1/keyless/map` (keyless), body
296    /// `{"url": url, "search": search, "limit"?: limit}`.
297    pub async fn map(&self, url: &str, opts: &MapOptions) -> Result<MapResult> {
298        let path = if self.inner.api_key.is_some() {
299            "/api/crawl/map"
300        } else {
301            "/v1/keyless/map"
302        };
303        let mut body = Map::new();
304        body.insert("url".into(), Value::String(url.to_string()));
305        body.insert(
306            "search".into(),
307            Value::String(opts.search.clone().unwrap_or_default()),
308        );
309        if let Some(limit) = opts.limit {
310            body.insert("limit".into(), Value::from(limit));
311        }
312        let raw = self
313            .send(Method::POST, path, Some(&Value::Object(body)))
314            .await?;
315        Ok(normalize_map(&raw, self.tier()))
316    }
317
318    /// Start a whole-site crawl. **METERED ONLY** — requires an API key; on the
319    /// keyless tier this returns [`WritError::ApiKeyRequired`] before any network
320    /// call (use [`CloudClient::scrape`]/[`CloudClient::map`] instead).
321    ///
322    /// `POST /api/crawl` with the [`CrawlStartParams`] body.
323    pub async fn crawl(&self, params: &CrawlStartParams) -> Result<CrawlJob> {
324        if self.inner.api_key.is_none() {
325            return Err(api_key_required(
326                "Whole-site crawl needs an API key — set api_key or WRIT_API_KEY. \
327                 Keyless access covers scrape and map only.",
328            ));
329        }
330        let body = serde_json::to_value(params)
331            .map_err(|e| WritError::Connection(format!("serializing crawl params: {e}")))?;
332        let raw = self.send(Method::POST, "/api/crawl", Some(&body)).await?;
333        decode_crawl_job(raw)
334    }
335
336    /// Poll a metered crawl's status (requires an API key).
337    ///
338    /// `GET /api/crawl/{id}`.
339    pub async fn crawl_status(&self, id: i64) -> Result<CrawlJob> {
340        if self.inner.api_key.is_none() {
341            return Err(api_key_required(
342                "Crawl status needs an API key — set api_key or WRIT_API_KEY.",
343            ));
344        }
345        let raw = self
346            .send(Method::GET, &format!("/api/crawl/{id}"), None)
347            .await?;
348        decode_crawl_job(raw)
349    }
350
351    /// Remaining keyless allowance for this install (keyless tier only; `None`
352    /// when metered).
353    ///
354    /// `GET /v1/keyless/quota`.
355    pub async fn quota(&self) -> Result<Option<KeylessQuota>> {
356        if self.inner.api_key.is_some() {
357            return Ok(None);
358        }
359        let raw = self.send(Method::GET, "/v1/keyless/quota", None).await?;
360        Ok(Some(normalize_quota(&raw)))
361    }
362
363    // --- transport ----------------------------------------------------------
364
365    async fn send(&self, method: Method, path: &str, json: Option<&Value>) -> Result<Value> {
366        let mut req = self
367            .inner
368            .http
369            .request(method, format!("{}{}", self.inner.base, path));
370        if let Some(key) = &self.inner.api_key {
371            req = req.header(AUTHORIZATION, format!("Bearer {key}"));
372        } else {
373            req = req.header(CLIENT_ID_HEADER, self.client_id());
374        }
375        if let Some(body) = json {
376            req = req.header(CONTENT_TYPE, "application/json").json(body);
377        }
378
379        let resp = req
380            .send()
381            .await
382            .map_err(|e| WritError::Connection(format!("cloud request to {path} failed: {e}")))?;
383        let status = resp.status().as_u16();
384        let text = resp
385            .text()
386            .await
387            .map_err(|e| WritError::Connection(format!("reading cloud response body: {e}")))?;
388
389        if !(200..300).contains(&status) {
390            return Err(cloud_error_from(status, &text));
391        }
392        if text.trim().is_empty() {
393            return Ok(Value::Object(Map::new()));
394        }
395        serde_json::from_str(&text)
396            .map_err(|e| WritError::Connection(format!("decoding cloud response body: {e}")))
397    }
398
399    /// The keyless client id: the explicit override, else the lazily
400    /// loaded/minted `~/.writ/client_id`.
401    fn client_id(&self) -> String {
402        if let Some(id) = &self.inner.client_id_override {
403            return id.clone();
404        }
405        self.inner
406            .client_id_cache
407            .get_or_init(load_or_mint_client_id)
408            .clone()
409    }
410}
411
412// --- error mapping ----------------------------------------------------------
413
414/// Build the client-side [`WritError::ApiKeyRequired`] (no network call).
415fn api_key_required(message: &str) -> WritError {
416    WritError::ApiKeyRequired {
417        status: 402,
418        code: "api_key_required".to_string(),
419        message: message.to_string(),
420        body: Value::Null,
421    }
422}
423
424/// Map a non-2xx Writ Cloud response body — `{"detail": {message, code,
425/// reset_at, requests_remaining, pages_remaining}}` (some errors are flat
426/// `{"code", "message"}`) — to a typed [`WritError`].
427fn cloud_error_from(status: u16, raw: &str) -> WritError {
428    let body: Value = serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
429    // `detail` may be a nested object, a bare string, or absent (flat body).
430    let detail = body.get("detail").cloned().unwrap_or_else(|| body.clone());
431    let d = detail.as_object();
432
433    let field_str = |key: &str| d.and_then(|m| m.get(key)).and_then(Value::as_str);
434    let field_i64 = |key: &str| d.and_then(|m| m.get(key)).and_then(Value::as_i64);
435
436    let code = field_str("code")
437        .map(str::to_string)
438        .unwrap_or_else(|| code_for_status(status));
439    let message = field_str("message")
440        .map(str::to_string)
441        .or_else(|| detail.as_str().map(str::to_string))
442        .unwrap_or_else(|| format!("HTTP {status}"));
443
444    match (status, code.as_str()) {
445        (429, _) => WritError::RateLimited {
446            status,
447            code,
448            message,
449            reset_at: field_str("reset_at").map(str::to_string),
450            requests_remaining: field_i64("requests_remaining"),
451            pages_remaining: field_i64("pages_remaining"),
452            body,
453        },
454        (402, "api_key_required") => WritError::ApiKeyRequired {
455            status,
456            code,
457            message,
458            body,
459        },
460        (402, _) => WritError::InsufficientCredits {
461            status,
462            code,
463            message,
464            body,
465        },
466        _ => WritError::Api {
467            status,
468            code,
469            message,
470            body,
471        },
472    }
473}
474
475// --- normalization ----------------------------------------------------------
476
477fn decode_crawl_job(raw: Value) -> Result<CrawlJob> {
478    serde_json::from_value(raw)
479        .map_err(|e| WritError::Connection(format!("decoding cloud crawl job: {e}")))
480}
481
482fn str_field(raw: &Value, key: &str) -> String {
483    raw.get(key)
484        .and_then(Value::as_str)
485        .unwrap_or_default()
486        .to_string()
487}
488
489fn opt_str_field(raw: &Value, key: &str) -> Option<String> {
490    raw.get(key).and_then(Value::as_str).map(str::to_string)
491}
492
493fn i64_field(raw: &Value, key: &str) -> i64 {
494    raw.get(key).and_then(Value::as_i64).unwrap_or(0)
495}
496
497fn normalize_quota(raw: &Value) -> KeylessQuota {
498    // The quota may sit under a `quota` envelope or be the object itself.
499    let q = raw.get("quota").unwrap_or(raw);
500    KeylessQuota {
501        tier: CloudTier::Keyless,
502        requests_remaining: i64_field(q, "requests_remaining"),
503        pages_remaining: i64_field(q, "pages_remaining"),
504        requests_per_day: i64_field(q, "requests_per_day"),
505        pages_per_day: i64_field(q, "pages_per_day"),
506        reset_at: str_field(q, "reset_at"),
507        upgrade_url: opt_str_field(q, "upgrade_url"),
508    }
509}
510
511fn normalize_scrape(raw: &Value, tier: CloudTier) -> ScrapeResult {
512    let format = {
513        let f = str_field(raw, "format");
514        if f.is_empty() {
515            "markdown".to_string()
516        } else {
517            f
518        }
519    };
520    ScrapeResult {
521        url: str_field(raw, "url"),
522        title: opt_str_field(raw, "title"),
523        format,
524        markdown: str_field(raw, "markdown"),
525        counts: raw
526            .get("counts")
527            .and_then(Value::as_object)
528            .cloned()
529            .unwrap_or_default(),
530        tier,
531        quota: raw.get("quota").map(|_| normalize_quota(raw)),
532    }
533}
534
535fn normalize_map(raw: &Value, tier: CloudTier) -> MapResult {
536    let urls = raw
537        .get("urls")
538        .and_then(Value::as_array)
539        .map(|arr| {
540            arr.iter()
541                .map(|entry| MapEntry {
542                    url: str_field(entry, "url"),
543                    score: entry.get("score").and_then(Value::as_f64).unwrap_or(0.0),
544                    title: opt_str_field(entry, "title"),
545                })
546                .collect()
547        })
548        .unwrap_or_default();
549    let counts = raw
550        .get("counts")
551        .map(|c| MapCounts {
552            returned: i64_field(c, "returned"),
553            total: i64_field(c, "total"),
554        })
555        .unwrap_or_default();
556    MapResult {
557        url: str_field(raw, "url"),
558        host: opt_str_field(raw, "host"),
559        urls,
560        counts,
561        tier,
562        quota: raw.get("quota").map(|_| normalize_quota(raw)),
563    }
564}
565
566// --- client id --------------------------------------------------------------
567
568/// `~/.writ` (via `$HOME` / `%USERPROFILE%`), the keyless client-id home.
569fn writ_home_dir() -> Option<PathBuf> {
570    std::env::var_os("HOME")
571        .or_else(|| std::env::var_os("USERPROFILE"))
572        .map(|home| PathBuf::from(home).join(".writ"))
573}
574
575/// Read (or mint + best-effort persist) the stable keyless device id at
576/// `~/.writ/client_id`. Any filesystem error falls back to an ephemeral id.
577fn load_or_mint_client_id() -> String {
578    let id = base64_url_nopad(&random_bytes_16());
579    let Some(dir) = writ_home_dir() else {
580        return id;
581    };
582    let file = dir.join("client_id");
583    if let Ok(existing) = std::fs::read_to_string(&file) {
584        let trimmed = existing.trim();
585        if !trimmed.is_empty() {
586            return trimmed.to_string();
587        }
588    }
589    // Best-effort persist; a read-only fs just keeps the ephemeral id.
590    let _ = std::fs::create_dir_all(&dir);
591    let _ = std::fs::write(&file, &id);
592    id
593}
594
595/// 16 bytes (128 bits) of entropy without pulling in a `rand`/`getrandom`
596/// dependency: two independently OS-seeded `RandomState` hashers, mixed with the
597/// pid / nanos / a process-global counter.
598fn random_bytes_16() -> [u8; 16] {
599    use std::collections::hash_map::RandomState;
600    use std::hash::{BuildHasher, Hash, Hasher};
601    use std::time::{SystemTime, UNIX_EPOCH};
602
603    static COUNTER: AtomicU64 = AtomicU64::new(0);
604    let nanos = SystemTime::now()
605        .duration_since(UNIX_EPOCH)
606        .map(|d| d.as_nanos() as u64)
607        .unwrap_or(0);
608    let seed = (
609        std::process::id() as u64,
610        nanos,
611        COUNTER.fetch_add(1, Ordering::Relaxed),
612    );
613
614    let mut out = [0u8; 16];
615    for (i, half) in out.chunks_mut(8).enumerate() {
616        // A fresh RandomState is seeded from OS randomness, so each finish()
617        // carries ~64 bits of entropy from the hasher keys alone.
618        let mut hasher = RandomState::new().build_hasher();
619        seed.hash(&mut hasher);
620        (i as u64).hash(&mut hasher);
621        half.copy_from_slice(&hasher.finish().to_le_bytes());
622    }
623    out
624}
625
626/// URL-safe base64, no padding (matches every other Writ SDK's client id).
627fn base64_url_nopad(bytes: &[u8]) -> String {
628    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
629    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
630    for chunk in bytes.chunks(3) {
631        let b0 = chunk[0] as u32;
632        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
633        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
634        let n = (b0 << 16) | (b1 << 8) | b2;
635        out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
636        out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
637        if chunk.len() > 1 {
638            out.push(ALPHABET[((n >> 6) & 63) as usize] as char);
639        }
640        if chunk.len() > 2 {
641            out.push(ALPHABET[(n & 63) as usize] as char);
642        }
643    }
644    out
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    #[test]
652    fn tier_from_credential() {
653        let metered = CloudClient::builder().api_key("wt_x").build().unwrap();
654        assert_eq!(metered.tier(), CloudTier::Metered);
655        assert_eq!(metered.tier().as_str(), "metered");
656
657        let keyless = CloudClient::builder().build().unwrap();
658        // `build()` still consults WRIT_API_KEY; assert only when it is unset so
659        // this stays deterministic in CI where the env is clean.
660        if std::env::var_os("WRIT_API_KEY").is_none() {
661            assert_eq!(keyless.tier(), CloudTier::Keyless);
662            assert_eq!(keyless.tier().as_str(), "keyless");
663        }
664    }
665
666    #[test]
667    fn cloud_url_default_and_trim() {
668        if std::env::var_os("WRIT_CLOUD_URL").is_none() {
669            let c = CloudClient::builder().build().unwrap();
670            assert_eq!(c.base_url(), "https://api.usewrit.app");
671        }
672        let c = CloudClient::builder()
673            .cloud_url("https://example.test/")
674            .build()
675            .unwrap();
676        assert_eq!(c.base_url(), "https://example.test");
677    }
678
679    #[test]
680    fn base64_url_nopad_matches_reference() {
681        // Classic RFC 4648 URL-safe, no-pad vectors.
682        assert_eq!(base64_url_nopad(b""), "");
683        assert_eq!(base64_url_nopad(b"f"), "Zg");
684        assert_eq!(base64_url_nopad(b"fo"), "Zm8");
685        assert_eq!(base64_url_nopad(b"foo"), "Zm9v");
686        assert_eq!(base64_url_nopad(b"foob"), "Zm9vYg");
687        // 16 bytes → 22 chars, no padding.
688        assert_eq!(base64_url_nopad(&[0u8; 16]).len(), 22);
689    }
690
691    #[test]
692    fn random_ids_are_distinct_and_url_safe() {
693        let a = base64_url_nopad(&random_bytes_16());
694        let b = base64_url_nopad(&random_bytes_16());
695        assert_ne!(a, b, "two mints must differ");
696        assert_eq!(a.len(), 22);
697        assert!(a
698            .chars()
699            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
700    }
701
702    #[test]
703    fn error_mapping_covers_each_tier_shape() {
704        // 429 → RateLimited with detail fields.
705        let err = cloud_error_from(
706            429,
707            r#"{"detail":{"code":"rate_limited","message":"slow down","reset_at":"2026-07-16T00:00:00Z","requests_remaining":0,"pages_remaining":3}}"#,
708        );
709        match err {
710            WritError::RateLimited {
711                reset_at,
712                requests_remaining,
713                pages_remaining,
714                message,
715                ..
716            } => {
717                assert_eq!(reset_at.as_deref(), Some("2026-07-16T00:00:00Z"));
718                assert_eq!(requests_remaining, Some(0));
719                assert_eq!(pages_remaining, Some(3));
720                assert_eq!(message, "slow down");
721            }
722            other => panic!("expected RateLimited, got {other:?}"),
723        }
724
725        // 402 api_key_required → ApiKeyRequired.
726        let err = cloud_error_from(
727            402,
728            r#"{"detail":{"code":"api_key_required","message":"key please"}}"#,
729        );
730        assert!(
731            matches!(err, WritError::ApiKeyRequired { .. }),
732            "got {err:?}"
733        );
734
735        // 402 otherwise → InsufficientCredits.
736        let err = cloud_error_from(
737            402,
738            r#"{"detail":{"code":"insufficient_credits","message":"broke"}}"#,
739        );
740        assert!(
741            matches!(err, WritError::InsufficientCredits { .. }),
742            "got {err:?}"
743        );
744
745        // Flat body + non-tier status → generic Api.
746        let err = cloud_error_from(400, r#"{"code":"bad_request","message":"nope"}"#);
747        match err {
748            WritError::Api { code, message, .. } => {
749                assert_eq!(code, "bad_request");
750                assert_eq!(message, "nope");
751            }
752            other => panic!("expected Api, got {other:?}"),
753        }
754    }
755}