Skip to main content

pastebin_ai/
api.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::time::Duration;
4use ureq;
5
6/// Visibility level for a paste.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum PasteVisibility {
10    /// Public: listed in public archive, searchable
11    Public,
12    /// Unlisted: accessible via direct link only (default)
13    Unlisted,
14    /// Private: only accessible by owner (requires authentication)
15    Private,
16}
17
18impl Default for PasteVisibility {
19    fn default() -> Self {
20        PasteVisibility::Unlisted
21    }
22}
23
24impl std::str::FromStr for PasteVisibility {
25    type Err = String;
26    fn from_str(s: &str) -> Result<Self, Self::Err> {
27        match s.to_lowercase().as_str() {
28            "public" => Ok(PasteVisibility::Public),
29            "unlisted" => Ok(PasteVisibility::Unlisted),
30            "private" => Ok(PasteVisibility::Private),
31            _ => Err(format!("Invalid visibility: {}", s)),
32        }
33    }
34}
35
36/// Expiration duration for a paste.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "lowercase")]
39pub enum PasteExpiration {
40    /// 10 minutes
41    #[serde(rename = "10m")]
42    Min10,
43    /// 1 hour
44    #[serde(rename = "1h")]
45    Hour1,
46    /// 1 day
47    #[serde(rename = "1d")]
48    Day1,
49    /// 1 week (default)
50    #[serde(rename = "1w")]
51    Week1,
52    /// 2 weeks
53    #[serde(rename = "2w")]
54    Week2,
55    /// 1 month
56    #[serde(rename = "1m")]
57    Month1,
58    /// 6 months
59    #[serde(rename = "6m")]
60    Month6,
61    /// 1 year
62    #[serde(rename = "1y")]
63    Year1,
64    /// Never expires
65    Never,
66}
67
68impl Default for PasteExpiration {
69    fn default() -> Self {
70        PasteExpiration::Week1
71    }
72}
73
74impl std::str::FromStr for PasteExpiration {
75    type Err = String;
76    fn from_str(s: &str) -> Result<Self, Self::Err> {
77        match s.to_lowercase().as_str() {
78            "10m" => Ok(PasteExpiration::Min10),
79            "1h" => Ok(PasteExpiration::Hour1),
80            "1d" => Ok(PasteExpiration::Day1),
81            "1w" => Ok(PasteExpiration::Week1),
82            "2w" => Ok(PasteExpiration::Week2),
83            "1m" => Ok(PasteExpiration::Month1),
84            "6m" => Ok(PasteExpiration::Month6),
85            "1y" => Ok(PasteExpiration::Year1),
86            "never" => Ok(PasteExpiration::Never),
87            _ => Err(format!("Invalid expiration: {}", s)),
88        }
89    }
90}
91
92/// Category classification for a paste.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(rename_all = "lowercase")]
95pub enum PasteCategory {
96    /// Source code snippets
97    Code,
98    /// Log files and stack traces
99    Logs,
100    /// Configuration files (YAML, TOML, etc.)
101    Configuration,
102    /// Data files (JSON, CSV, etc.)
103    Data,
104    /// Documentation and markdown
105    Documentation,
106    /// Other content
107    Other,
108}
109
110impl Default for PasteCategory {
111    fn default() -> Self {
112        PasteCategory::Code
113    }
114}
115
116impl std::str::FromStr for PasteCategory {
117    type Err = String;
118    fn from_str(s: &str) -> Result<Self, Self::Err> {
119        match s.to_lowercase().as_str() {
120            "code" => Ok(PasteCategory::Code),
121            "logs" => Ok(PasteCategory::Logs),
122            "configuration" => Ok(PasteCategory::Configuration),
123            "data" => Ok(PasteCategory::Data),
124            "documentation" => Ok(PasteCategory::Documentation),
125            "other" => Ok(PasteCategory::Other),
126            _ => Err(format!("Invalid category: {}", s)),
127        }
128    }
129}
130
131/// Request payload for creating a new paste.
132#[derive(Debug, Serialize, Clone)]
133pub struct CreatePasteRequest {
134    /// The text content (required, max 1 MB UTF-8)
135    pub content: String,
136    /// Optional title for the paste
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub title: Option<String>,
139    /// Syntax highlighting language (e.g., "rust", "python", "json")
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub language: Option<String>,
142    /// Visibility level
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub visibility: Option<PasteVisibility>,
145    /// Expiration duration
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub expiration: Option<PasteExpiration>,
148    /// Category classification
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub category: Option<PasteCategory>,
151    /// Comma- or space-separated tags (max 8 retained)
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub tags: Option<String>,
154    /// Maximum unique views (1-1000)
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub max_views: Option<u32>,
157    /// Password for protected access (4-128 chars)
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub password: Option<String>,
160    /// Delete after first read
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub burn_after_read: Option<bool>,
163    /// Folder ID (requires auth)
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub folder_id: Option<u64>,
166    /// Content is pre-encrypted ciphertext
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub encrypted: Option<bool>,
169}
170
171/// Response from successful paste creation.
172#[derive(Debug, Deserialize)]
173pub struct PasteCreated {
174    /// Unique paste ID
175    pub id: String,
176    /// Web UI URL for the paste
177    pub url: String,
178    /// Raw content URL
179    pub raw_url: String,
180    /// Token for anonymous deletion (save this!)
181    pub delete_token: String,
182    /// Expiration timestamp (if applicable)
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub expires_at: Option<String>,
185    /// Maximum views limit (if set)
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub max_views: Option<u32>,
188}
189
190/// Paste metadata and content.
191#[derive(Debug, Deserialize)]
192pub struct Paste {
193    /// Unique paste ID
194    pub id: String,
195    /// Paste title (if set)
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub title: Option<String>,
198    /// Paste content
199    pub content: String,
200    /// Syntax highlighting language
201    pub language: String,
202    /// Visibility level
203    pub visibility: String,
204    /// View count
205    pub views: u32,
206    /// Creation timestamp
207    pub created_at: String,
208    /// Expiration timestamp (if applicable)
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub expires_at: Option<String>,
211    /// Maximum views limit (if set)
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub max_views: Option<u32>,
214    /// Last update timestamp (if updated)
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub updated_at: Option<String>,
217    /// Web UI URL (for owned pastes)
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub url: Option<String>,
220    /// Category (if set)
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub category: Option<String>,
223    /// Tags (if set)
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub tags: Option<String>,
226    /// Burn after read flag
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub burn_after_read: Option<bool>,
229    /// Encrypted flag
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub encrypted: Option<bool>,
232    /// Folder ID (if in folder)
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub folder_id: Option<u64>,
235}
236
237#[derive(Debug, Deserialize)]
238struct ErrorResponse {
239    error: String,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    _retry_after: Option<u64>,
242}
243
244const BASE_URL: &str = "https://pastebin.ai/api/v1";
245
246/// HTTP client for the pastebin.ai API.
247///
248/// Supports both anonymous and authenticated operations.
249/// Use `new()` for anonymous access, `with_token()` for authenticated access,
250/// or `with_delete_token()` to delete an anonymous paste using its delete token.
251pub struct PastebinClient {
252    agent: ureq::Agent,
253    auth_token: Option<String>,
254    delete_token: Option<String>,
255    delete_paste_id: Option<String>,
256}
257
258impl PastebinClient {
259    /// Create a new anonymous client.
260    pub fn new() -> Result<Self> {
261        let agent = ureq::AgentBuilder::new()
262            .timeout_connect(Duration::from_secs(10))
263            .timeout_read(Duration::from_secs(30))
264            .timeout_write(Duration::from_secs(30))
265            .user_agent("pastebin-ai-cli/0.1.0")
266            .build();
267        Ok(Self {
268            agent,
269            auth_token: None,
270            delete_token: None,
271            delete_paste_id: None,
272        })
273    }
274
275    /// Create an authenticated client with a Bearer token.
276    ///
277    /// Get your token from <https://pastebin.ai/settings/api> (starts with `pba_`).
278    pub fn with_token(token: &str) -> Result<Self> {
279        let mut client = Self::new()?;
280        client.auth_token = Some(token.to_string());
281        Ok(client)
282    }
283
284    /// Create a client for deleting a specific anonymous paste.
285    ///
286    /// Use the delete token returned when creating the paste.
287    pub fn with_delete_token(paste_id: &str, token: &str) -> Result<Self> {
288        let mut client = Self::new()?;
289        client.delete_token = Some(token.to_string());
290        client.delete_paste_id = Some(paste_id.to_string());
291        Ok(client)
292    }
293
294    /// Create a new paste.
295    ///
296    /// Returns [`PasteCreated`] with the paste URL, raw URL, and delete token.
297    pub fn create_paste(&self, request: CreatePasteRequest) -> Result<PasteCreated> {
298        let req = self.agent.post(&format!("{}/pastes", BASE_URL)).send_json(&request)?;
299
300        let status = req.status();
301        let body = req.into_string()?;
302
303        if (200..300).contains(&status) {
304            Ok(serde_json::from_str(&body)?)
305        } else {
306            let err: ErrorResponse = serde_json::from_str(&body).unwrap_or(ErrorResponse {
307                error: format!("HTTP {}", status),
308                _retry_after: None,
309            });
310            anyhow::bail!("API error: {}", err.error);
311        }
312    }
313
314    /// Retrieve a paste by ID.
315    ///
316    /// Works for public and unlisted pastes. Private/password-protected/encrypted
317    /// pastes require authentication and return 404.
318    pub fn get_paste(&self, id: &str) -> Result<Paste> {
319        let url = format!("{}/pastes/{}", BASE_URL, id);
320
321        let resp = self.agent.get(&url).call()?;
322
323        let status = resp.status();
324        let body = resp.into_string()?;
325
326        if (200..300).contains(&status) {
327            Ok(serde_json::from_str(&body)?)
328        } else if status == 404 {
329            anyhow::bail!("Paste not found: {}", id);
330        } else {
331            let err: ErrorResponse = serde_json::from_str(&body).unwrap_or(ErrorResponse {
332                error: format!("HTTP {}", status),
333                _retry_after: None,
334            });
335            anyhow::bail!("API error: {}", err.error);
336        }
337    }
338
339    /// List pastes owned by the authenticated account.
340    ///
341    /// Requires authentication via `with_token()`. Returns up to `limit` pastes.
342    pub fn list_pastes(&self, limit: usize) -> Result<Vec<Paste>> {
343        let token = self.auth_token.as_ref()
344            .ok_or_else(|| anyhow::anyhow!("Authentication required"))?;
345
346        let resp = self.agent
347            .get(&format!("{}/account/pastes", BASE_URL))
348            .set("Authorization", &format!("Bearer {}", token))
349            .call()?;
350
351        let status = resp.status();
352        let body = resp.into_string()?;
353
354        if (200..300).contains(&status) {
355            let pastes: Vec<Paste> = serde_json::from_str(&body)?;
356            Ok(pastes.into_iter().take(limit).collect())
357        } else {
358            let err: ErrorResponse = serde_json::from_str(&body).unwrap_or(ErrorResponse {
359                error: format!("HTTP {}", status),
360                _retry_after: None,
361            });
362            anyhow::bail!("API error: {}", err.error);
363        }
364    }
365
366    /// Delete a paste.
367    ///
368    /// For anonymous pastes created with this client's delete token, uses the
369    /// public delete endpoint with `X-Delete-Token` header.
370    /// For authenticated pastes, uses the account endpoint with Bearer token.
371    pub fn delete_paste(&self, id: &str) -> Result<()> {
372        // Use different endpoint depending on auth type
373        let (_url, req) = if let (Some(token), Some(paste_id)) = (&self.delete_token, &self.delete_paste_id) {
374            if paste_id == id {
375                // Anonymous paste: use public endpoint with X-Delete-Token
376                let url = format!("{}/pastes/{}", BASE_URL, id);
377                let req = self.agent.delete(&url).set("X-Delete-Token", token);
378                (url, req)
379            } else if let Some(auth) = &self.auth_token {
380                // Wrong delete token for this paste, fall back to auth
381                let url = format!("{}/account/pastes/{}", BASE_URL, id);
382                let req = self.agent.delete(&url).set("Authorization", &format!("Bearer {}", auth));
383                (url, req)
384            } else {
385                anyhow::bail!("No valid authentication for delete");
386            }
387        } else if let Some(auth) = &self.auth_token {
388            // Authenticated paste: use account endpoint with Bearer token
389            let url = format!("{}/account/pastes/{}", BASE_URL, id);
390            let req = self.agent.delete(&url).set("Authorization", &format!("Bearer {}", auth));
391            (url, req)
392        } else {
393            anyhow::bail!("No valid authentication for delete");
394        };
395
396        let resp = req.call()?;
397        let status = resp.status();
398
399        if (200..300).contains(&status) || status == 204 {
400            Ok(())
401        } else if status == 404 {
402            anyhow::bail!("Paste not found: {}", id);
403        } else {
404            let body = resp.into_string()?;
405            let err: ErrorResponse = serde_json::from_str(&body).unwrap_or(ErrorResponse {
406                error: format!("HTTP {}", status),
407                _retry_after: None,
408            });
409            anyhow::bail!("API error: {}", err.error);
410        }
411    }
412}