1use 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
44const DEFAULT_CLOUD_URL: &str = "https://api.usewrit.app";
46
47const CLIENT_ID_HEADER: &str = "X-Writ-Client-Id";
49
50const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum CloudTier {
56 Keyless,
58 Metered,
60}
61
62impl CloudTier {
63 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#[derive(Debug, Clone)]
81pub struct KeylessQuota {
82 pub tier: CloudTier,
84 pub requests_remaining: i64,
86 pub pages_remaining: i64,
88 pub requests_per_day: i64,
90 pub pages_per_day: i64,
92 pub reset_at: String,
94 pub upgrade_url: Option<String>,
96}
97
98#[derive(Debug, Clone)]
100pub struct ScrapeResult {
101 pub url: String,
103 pub title: Option<String>,
105 pub format: String,
107 pub markdown: String,
109 pub counts: Map<String, Value>,
111 pub tier: CloudTier,
113 pub quota: Option<KeylessQuota>,
115}
116
117#[derive(Debug, Clone)]
119pub struct MapEntry {
120 pub url: String,
122 pub score: f64,
124 pub title: Option<String>,
126}
127
128#[derive(Debug, Clone, Default)]
130pub struct MapCounts {
131 pub returned: i64,
133 pub total: i64,
135}
136
137#[derive(Debug, Clone)]
139pub struct MapResult {
140 pub url: String,
142 pub host: Option<String>,
144 pub urls: Vec<MapEntry>,
146 pub counts: MapCounts,
148 pub tier: CloudTier,
150 pub quota: Option<KeylessQuota>,
152}
153
154#[derive(Debug, Clone, Default)]
156pub struct MapOptions {
157 pub search: Option<String>,
159 pub limit: Option<i64>,
161}
162
163#[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 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 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 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 pub fn timeout(mut self, timeout: Duration) -> Self {
197 self.timeout = Some(timeout);
198 self
199 }
200
201 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#[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 pub fn builder() -> CloudClientBuilder {
253 CloudClientBuilder::default()
254 }
255
256 pub fn from_env() -> Result<CloudClient> {
259 CloudClientBuilder::default().build()
260 }
261
262 pub fn base_url(&self) -> &str {
264 &self.inner.base
265 }
266
267 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 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 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 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 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 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 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 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
412fn 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
424fn 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 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
475fn 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 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
566fn 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
575fn 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 let _ = std::fs::create_dir_all(&dir);
591 let _ = std::fs::write(&file, &id);
592 id
593}
594
595fn 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 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
626fn 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 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 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 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 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 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 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 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}