Skip to main content

pastebin_ai/
api.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::time::Duration;
4use ureq;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum PasteVisibility {
9    Public,
10    Unlisted,
11    Private,
12}
13
14impl Default for PasteVisibility {
15    fn default() -> Self {
16        PasteVisibility::Unlisted
17    }
18}
19
20impl std::str::FromStr for PasteVisibility {
21    type Err = String;
22    fn from_str(s: &str) -> Result<Self, Self::Err> {
23        match s.to_lowercase().as_str() {
24            "public" => Ok(PasteVisibility::Public),
25            "unlisted" => Ok(PasteVisibility::Unlisted),
26            "private" => Ok(PasteVisibility::Private),
27            _ => Err(format!("Invalid visibility: {}", s)),
28        }
29    }
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum PasteExpiration {
35    #[serde(rename = "10m")]
36    Min10,
37    #[serde(rename = "1h")]
38    Hour1,
39    #[serde(rename = "1d")]
40    Day1,
41    #[serde(rename = "1w")]
42    Week1,
43    #[serde(rename = "2w")]
44    Week2,
45    #[serde(rename = "1m")]
46    Month1,
47    #[serde(rename = "6m")]
48    Month6,
49    #[serde(rename = "1y")]
50    Year1,
51    Never,
52}
53
54impl Default for PasteExpiration {
55    fn default() -> Self {
56        PasteExpiration::Week1
57    }
58}
59
60impl std::str::FromStr for PasteExpiration {
61    type Err = String;
62    fn from_str(s: &str) -> Result<Self, Self::Err> {
63        match s.to_lowercase().as_str() {
64            "10m" => Ok(PasteExpiration::Min10),
65            "1h" => Ok(PasteExpiration::Hour1),
66            "1d" => Ok(PasteExpiration::Day1),
67            "1w" => Ok(PasteExpiration::Week1),
68            "2w" => Ok(PasteExpiration::Week2),
69            "1m" => Ok(PasteExpiration::Month1),
70            "6m" => Ok(PasteExpiration::Month6),
71            "1y" => Ok(PasteExpiration::Year1),
72            "never" => Ok(PasteExpiration::Never),
73            _ => Err(format!("Invalid expiration: {}", s)),
74        }
75    }
76}
77
78impl PasteExpiration {
79    pub fn as_str(&self) -> &'static str {
80        match self {
81            PasteExpiration::Min10 => "10m",
82            PasteExpiration::Hour1 => "1h",
83            PasteExpiration::Day1 => "1d",
84            PasteExpiration::Week1 => "1w",
85            PasteExpiration::Week2 => "2w",
86            PasteExpiration::Month1 => "1m",
87            PasteExpiration::Month6 => "6m",
88            PasteExpiration::Year1 => "1y",
89            PasteExpiration::Never => "never",
90        }
91    }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum PasteCategory {
97    Code,
98    Logs,
99    Configuration,
100    Data,
101    Documentation,
102    Other,
103}
104
105impl Default for PasteCategory {
106    fn default() -> Self {
107        PasteCategory::Code
108    }
109}
110
111impl std::str::FromStr for PasteCategory {
112    type Err = String;
113    fn from_str(s: &str) -> Result<Self, Self::Err> {
114        match s.to_lowercase().as_str() {
115            "code" => Ok(PasteCategory::Code),
116            "logs" => Ok(PasteCategory::Logs),
117            "configuration" => Ok(PasteCategory::Configuration),
118            "data" => Ok(PasteCategory::Data),
119            "documentation" => Ok(PasteCategory::Documentation),
120            "other" => Ok(PasteCategory::Other),
121            _ => Err(format!("Invalid category: {}", s)),
122        }
123    }
124}
125
126#[derive(Debug, Serialize)]
127pub struct CreatePasteRequest {
128    pub content: String,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub title: Option<String>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub language: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub visibility: Option<PasteVisibility>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub expiration: Option<PasteExpiration>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub category: Option<PasteCategory>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub tags: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub max_views: Option<u32>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub password: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub burn_after_read: Option<bool>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub folder_id: Option<u64>,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub encrypted: Option<bool>,
151}
152
153#[derive(Debug, Deserialize)]
154pub struct PasteCreated {
155    pub id: String,
156    pub url: String,
157    pub raw_url: String,
158    pub delete_token: String,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub expires_at: Option<String>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub max_views: Option<u32>,
163}
164
165#[derive(Debug, Deserialize)]
166pub struct Paste {
167    pub id: String,
168    pub title: Option<String>,
169    pub content: String,
170    pub language: String,
171    pub visibility: String,
172    pub views: u32,
173    pub created_at: String,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub expires_at: Option<String>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub max_views: Option<u32>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub updated_at: Option<String>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub url: Option<String>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub category: Option<String>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub tags: Option<String>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub burn_after_read: Option<bool>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub encrypted: Option<bool>,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub folder_id: Option<u64>,
192}
193
194#[derive(Debug, Deserialize)]
195struct ErrorResponse {
196    error: String,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    retry_after: Option<u64>,
199}
200
201const BASE_URL: &str = "https://pastebin.ai/api/v1";
202
203pub struct PastebinClient {
204    agent: ureq::Agent,
205    auth_token: Option<String>,
206    delete_token: Option<String>,
207    delete_paste_id: Option<String>,
208}
209
210impl PastebinClient {
211    pub fn new() -> Result<Self> {
212        let agent = ureq::AgentBuilder::new()
213            .timeout_connect(Duration::from_secs(10))
214            .timeout_read(Duration::from_secs(30))
215            .timeout_write(Duration::from_secs(30))
216            .user_agent("pastebin-ai-cli/0.1.0")
217            .build();
218        Ok(Self {
219            agent,
220            auth_token: None,
221            delete_token: None,
222            delete_paste_id: None,
223        })
224    }
225
226    pub fn with_token(token: &str) -> Result<Self> {
227        let mut client = Self::new()?;
228        client.auth_token = Some(token.to_string());
229        Ok(client)
230    }
231
232    pub fn with_delete_token(paste_id: &str, token: &str) -> Result<Self> {
233        let mut client = Self::new()?;
234        client.delete_token = Some(token.to_string());
235        client.delete_paste_id = Some(paste_id.to_string());
236        Ok(client)
237    }
238
239    fn request_with_auth(&self, req: ureq::Request) -> ureq::Request {
240        if let Some(token) = &self.auth_token {
241            req.set("Authorization", &format!("Bearer {}", token))
242        } else {
243            req
244        }
245    }
246
247    pub fn create_paste(&self, request: CreatePasteRequest) -> Result<PasteCreated> {
248        let mut req = self.agent.post(&format!("{}/pastes", BASE_URL)).send_json(&request)?;
249
250        let status = req.status();
251        let body = req.into_string()?;
252
253        if (200..300).contains(&status) {
254            Ok(serde_json::from_str(&body)?)
255        } else {
256            let err: ErrorResponse = serde_json::from_str(&body).unwrap_or(ErrorResponse {
257                error: format!("HTTP {}", status),
258                retry_after: None,
259            });
260            anyhow::bail!("API error: {}", err.error);
261        }
262    }
263
264    pub fn get_paste(&self, id: &str) -> Result<Paste> {
265        let url = format!("{}/pastes/{}", BASE_URL, id);
266        
267        let resp = self.agent.get(&url).call()?;
268
269        let status = resp.status();
270        let body = resp.into_string()?;
271
272        if (200..300).contains(&status) {
273            Ok(serde_json::from_str(&body)?)
274        } else if status == 404 {
275            anyhow::bail!("Paste not found: {}", id);
276        } else {
277            let err: ErrorResponse = serde_json::from_str(&body).unwrap_or(ErrorResponse {
278                error: format!("HTTP {}", status),
279                retry_after: None,
280            });
281            anyhow::bail!("API error: {}", err.error);
282        }
283    }
284
285    pub fn list_pastes(&self, limit: usize) -> Result<Vec<Paste>> {
286        let token = self.auth_token.as_ref()
287            .ok_or_else(|| anyhow::anyhow!("Authentication required"))?;
288
289        let resp = self.agent
290            .get(&format!("{}/account/pastes", BASE_URL))
291            .set("Authorization", &format!("Bearer {}", token))
292            .call()?;
293
294        let status = resp.status();
295        let body = resp.into_string()?;
296
297        if (200..300).contains(&status) {
298            let pastes: Vec<Paste> = serde_json::from_str(&body)?;
299            Ok(pastes.into_iter().take(limit).collect())
300        } else {
301            let err: ErrorResponse = serde_json::from_str(&body).unwrap_or(ErrorResponse {
302                error: format!("HTTP {}", status),
303                retry_after: None,
304            });
305            anyhow::bail!("API error: {}", err.error);
306        }
307    }
308
309    pub fn delete_paste(&self, id: &str) -> Result<()> {
310        // Use different endpoint depending on auth type
311        let (_url, req) = if let (Some(token), Some(paste_id)) = (&self.delete_token, &self.delete_paste_id) {
312            if paste_id == id {
313                // Anonymous paste: use public endpoint with X-Delete-Token
314                let url = format!("{}/pastes/{}", BASE_URL, id);
315                let req = self.agent.delete(&url).set("X-Delete-Token", token);
316                (url, req)
317            } else if let Some(auth) = &self.auth_token {
318                // Wrong delete token for this paste, fall back to auth
319                let url = format!("{}/account/pastes/{}", BASE_URL, id);
320                let req = self.agent.delete(&url).set("Authorization", &format!("Bearer {}", auth));
321                (url, req)
322            } else {
323                anyhow::bail!("No valid authentication for delete");
324            }
325        } else if let Some(auth) = &self.auth_token {
326            // Authenticated paste: use account endpoint with Bearer token
327            let url = format!("{}/account/pastes/{}", BASE_URL, id);
328            let req = self.agent.delete(&url).set("Authorization", &format!("Bearer {}", auth));
329            (url, req)
330        } else {
331            anyhow::bail!("No valid authentication for delete");
332        };
333
334        
335        let resp = req.call()?;
336        let status = resp.status();
337
338        if (200..300).contains(&status) || status == 204 {
339            Ok(())
340        } else if status == 404 {
341            anyhow::bail!("Paste not found: {}", id);
342        } else {
343            let body = resp.into_string()?;
344            let err: ErrorResponse = serde_json::from_str(&body).unwrap_or(ErrorResponse {
345                error: format!("HTTP {}", status),
346                retry_after: None,
347            });
348            anyhow::bail!("API error: {}", err.error);
349        }
350    }
351}