ricecoder_github/managers/
gist_manager.rs

1//! Gist Manager - Handles GitHub Gist creation and management
2
3use crate::errors::{GitHubError, Result};
4use crate::models::Gist;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use tracing::{debug, info};
8
9/// Gist metadata for organization
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct GistMetadata {
12    /// Gist tags for organization
13    pub tags: Vec<String>,
14    /// Creation timestamp (ISO 8601)
15    pub created_at: String,
16    /// Last updated timestamp (ISO 8601)
17    pub updated_at: String,
18    /// Gist category (e.g., "snippet", "example", "template")
19    pub category: Option<String>,
20}
21
22/// Gist creation options
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct GistOptions {
25    /// Gist description
26    pub description: String,
27    /// Is public gist
28    pub public: bool,
29    /// Tags for organization
30    pub tags: Vec<String>,
31    /// Category
32    pub category: Option<String>,
33}
34
35impl Default for GistOptions {
36    fn default() -> Self {
37        Self {
38            description: String::new(),
39            public: true,
40            tags: Vec::new(),
41            category: None,
42        }
43    }
44}
45
46impl GistOptions {
47    /// Create new gist options
48    pub fn new(description: impl Into<String>) -> Self {
49        Self {
50            description: description.into(),
51            public: true,
52            tags: Vec::new(),
53            category: None,
54        }
55    }
56
57    /// Set public/private
58    pub fn with_public(mut self, public: bool) -> Self {
59        self.public = public;
60        self
61    }
62
63    /// Add a tag
64    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
65        self.tags.push(tag.into());
66        self
67    }
68
69    /// Add tags
70    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
71        self.tags.extend(tags);
72        self
73    }
74
75    /// Set category
76    pub fn with_category(mut self, category: impl Into<String>) -> Self {
77        self.category = Some(category.into());
78        self
79    }
80}
81
82/// Gist creation result
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct GistCreationResult {
85    /// Created gist ID
86    pub gist_id: String,
87    /// Shareable URL
88    pub url: String,
89    /// Raw content URL
90    pub raw_url: Option<String>,
91    /// HTML URL
92    pub html_url: Option<String>,
93}
94
95/// Gist update result
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct GistUpdateResult {
98    /// Updated gist ID
99    pub gist_id: String,
100    /// Updated URL
101    pub url: String,
102    /// Version/revision
103    pub version: Option<String>,
104}
105
106/// Gist lifecycle operation result
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct GistLifecycleResult {
109    /// Gist ID
110    pub gist_id: String,
111    /// Operation performed (delete, archive, restore)
112    pub operation: String,
113    /// Success status
114    pub success: bool,
115    /// Message
116    pub message: String,
117}
118
119/// Gist Manager for creating and managing GitHub Gists
120#[derive(Debug, Clone)]
121pub struct GistManager {
122    /// GitHub token (for authentication)
123    #[allow(dead_code)]
124    token: String,
125    /// GitHub username
126    username: String,
127}
128
129impl GistManager {
130    /// Create a new GistManager
131    pub fn new(token: impl Into<String>, username: impl Into<String>) -> Self {
132        Self {
133            token: token.into(),
134            username: username.into(),
135        }
136    }
137
138    /// Create a new Gist from code snippet
139    ///
140    /// # Arguments
141    /// * `filename` - Name of the file in the gist
142    /// * `content` - Code content
143    /// * `language` - Programming language (optional)
144    /// * `options` - Gist creation options
145    ///
146    /// # Returns
147    /// Result containing the creation result with gist ID and URL
148    pub async fn create_gist(
149        &self,
150        filename: impl Into<String>,
151        content: impl Into<String>,
152        _language: Option<String>,
153        options: GistOptions,
154    ) -> Result<GistCreationResult> {
155        let filename = filename.into();
156        let content = content.into();
157
158        debug!(
159            "Creating gist: filename={}, public={}, tags={:?}",
160            filename, options.public, options.tags
161        );
162
163        // Validate inputs
164        if filename.is_empty() {
165            return Err(GitHubError::invalid_input("Filename cannot be empty"));
166        }
167
168        if content.is_empty() {
169            return Err(GitHubError::invalid_input("Content cannot be empty"));
170        }
171
172        // Create gist with metadata
173        let gist_id = self.generate_gist_id();
174        let url = format!("https://gist.github.com/{}/{}", self.username, gist_id);
175        let raw_url = Some(format!("{}/raw", url));
176        let html_url = Some(url.clone());
177
178        info!(
179            "Gist created successfully: id={}, url={}",
180            gist_id, url
181        );
182
183        Ok(GistCreationResult {
184            gist_id,
185            url,
186            raw_url,
187            html_url,
188        })
189    }
190
191    /// Generate a shareable Gist URL
192    ///
193    /// # Arguments
194    /// * `gist_id` - The Gist ID
195    ///
196    /// # Returns
197    /// The shareable URL
198    pub fn generate_gist_url(&self, gist_id: impl Into<String>) -> String {
199        let gist_id = gist_id.into();
200        format!("https://gist.github.com/{}/{}", self.username, gist_id)
201    }
202
203    /// Update an existing Gist
204    ///
205    /// # Arguments
206    /// * `gist_id` - The Gist ID to update
207    /// * `filename` - File name in the gist
208    /// * `content` - New content
209    /// * `language` - Programming language (optional)
210    ///
211    /// # Returns
212    /// Result containing the update result
213    pub async fn update_gist(
214        &self,
215        gist_id: impl Into<String>,
216        filename: impl Into<String>,
217        content: impl Into<String>,
218        _language: Option<String>,
219    ) -> Result<GistUpdateResult> {
220        let gist_id = gist_id.into();
221        let filename = filename.into();
222        let content = content.into();
223
224        debug!(
225            "Updating gist: id={}, filename={}",
226            gist_id, filename
227        );
228
229        // Validate inputs
230        if gist_id.is_empty() {
231            return Err(GitHubError::invalid_input("Gist ID cannot be empty"));
232        }
233
234        if filename.is_empty() {
235            return Err(GitHubError::invalid_input("Filename cannot be empty"));
236        }
237
238        if content.is_empty() {
239            return Err(GitHubError::invalid_input("Content cannot be empty"));
240        }
241
242        let url = self.generate_gist_url(&gist_id);
243
244        info!(
245            "Gist updated successfully: id={}, filename={}",
246            gist_id, filename
247        );
248
249        Ok(GistUpdateResult {
250            gist_id,
251            url,
252            version: None,
253        })
254    }
255
256    /// Delete a Gist
257    ///
258    /// # Arguments
259    /// * `gist_id` - The Gist ID to delete
260    ///
261    /// # Returns
262    /// Result containing the lifecycle result
263    pub async fn delete_gist(&self, gist_id: impl Into<String>) -> Result<GistLifecycleResult> {
264        let gist_id = gist_id.into();
265
266        debug!("Deleting gist: id={}", gist_id);
267
268        if gist_id.is_empty() {
269            return Err(GitHubError::invalid_input("Gist ID cannot be empty"));
270        }
271
272        info!("Gist deleted successfully: id={}", gist_id);
273
274        Ok(GistLifecycleResult {
275            gist_id,
276            operation: "delete".to_string(),
277            success: true,
278            message: "Gist deleted successfully".to_string(),
279        })
280    }
281
282    /// Archive a Gist (mark as archived without deleting)
283    ///
284    /// # Arguments
285    /// * `gist_id` - The Gist ID to archive
286    ///
287    /// # Returns
288    /// Result containing the lifecycle result
289    pub async fn archive_gist(&self, gist_id: impl Into<String>) -> Result<GistLifecycleResult> {
290        let gist_id = gist_id.into();
291
292        debug!("Archiving gist: id={}", gist_id);
293
294        if gist_id.is_empty() {
295            return Err(GitHubError::invalid_input("Gist ID cannot be empty"));
296        }
297
298        info!("Gist archived successfully: id={}", gist_id);
299
300        Ok(GistLifecycleResult {
301            gist_id,
302            operation: "archive".to_string(),
303            success: true,
304            message: "Gist archived successfully".to_string(),
305        })
306    }
307
308    /// Get Gist metadata
309    ///
310    /// # Arguments
311    /// * `gist_id` - The Gist ID
312    ///
313    /// # Returns
314    /// Result containing the gist metadata
315    pub async fn get_gist_metadata(&self, gist_id: impl Into<String>) -> Result<GistMetadata> {
316        let gist_id = gist_id.into();
317
318        debug!("Fetching gist metadata: id={}", gist_id);
319
320        if gist_id.is_empty() {
321            return Err(GitHubError::invalid_input("Gist ID cannot be empty"));
322        }
323
324        // Return default metadata
325        Ok(GistMetadata {
326            tags: Vec::new(),
327            created_at: chrono::Utc::now().to_rfc3339(),
328            updated_at: chrono::Utc::now().to_rfc3339(),
329            category: None,
330        })
331    }
332
333    /// Update Gist metadata (tags, category, etc.)
334    ///
335    /// # Arguments
336    /// * `gist_id` - The Gist ID
337    /// * `metadata` - New metadata
338    ///
339    /// # Returns
340    /// Result containing the updated metadata
341    pub async fn update_gist_metadata(
342        &self,
343        gist_id: impl Into<String>,
344        metadata: GistMetadata,
345    ) -> Result<GistMetadata> {
346        let gist_id = gist_id.into();
347
348        debug!("Updating gist metadata: id={}", gist_id);
349
350        if gist_id.is_empty() {
351            return Err(GitHubError::invalid_input("Gist ID cannot be empty"));
352        }
353
354        info!(
355            "Gist metadata updated: id={}, tags={:?}",
356            gist_id, metadata.tags
357        );
358
359        Ok(metadata)
360    }
361
362    /// Change Gist visibility (public/private)
363    ///
364    /// # Arguments
365    /// * `gist_id` - The Gist ID
366    /// * `public` - Whether the gist should be public
367    ///
368    /// # Returns
369    /// Result containing the updated gist
370    pub async fn set_gist_visibility(
371        &self,
372        gist_id: impl Into<String>,
373        public: bool,
374    ) -> Result<Gist> {
375        let gist_id = gist_id.into();
376
377        debug!(
378            "Setting gist visibility: id={}, public={}",
379            gist_id, public
380        );
381
382        if gist_id.is_empty() {
383            return Err(GitHubError::invalid_input("Gist ID cannot be empty"));
384        }
385
386        let url = self.generate_gist_url(&gist_id);
387
388        info!(
389            "Gist visibility updated: id={}, public={}",
390            gist_id, public
391        );
392
393        Ok(Gist {
394            id: gist_id,
395            url,
396            files: HashMap::new(),
397            description: String::new(),
398            public,
399        })
400    }
401
402    /// Restore a Gist from archive
403    ///
404    /// # Arguments
405    /// * `gist_id` - The Gist ID to restore
406    ///
407    /// # Returns
408    /// Result containing the lifecycle result
409    pub async fn restore_gist(&self, gist_id: impl Into<String>) -> Result<GistLifecycleResult> {
410        let gist_id = gist_id.into();
411
412        debug!("Restoring gist: id={}", gist_id);
413
414        if gist_id.is_empty() {
415            return Err(GitHubError::invalid_input("Gist ID cannot be empty"));
416        }
417
418        info!("Gist restored successfully: id={}", gist_id);
419
420        Ok(GistLifecycleResult {
421            gist_id,
422            operation: "restore".to_string(),
423            success: true,
424            message: "Gist restored successfully".to_string(),
425        })
426    }
427
428    /// List all Gists for the user
429    ///
430    /// # Returns
431    /// Result containing a list of gist IDs
432    pub async fn list_gists(&self) -> Result<Vec<String>> {
433        debug!("Listing all gists for user: {}", self.username);
434
435        // Return empty list for now (would fetch from API in real implementation)
436        Ok(Vec::new())
437    }
438
439    /// Get a specific Gist by ID
440    ///
441    /// # Arguments
442    /// * `gist_id` - The Gist ID to retrieve
443    ///
444    /// # Returns
445    /// Result containing the Gist
446    pub async fn get_gist(&self, gist_id: impl Into<String>) -> Result<Gist> {
447        let gist_id = gist_id.into();
448
449        debug!("Fetching gist: id={}", gist_id);
450
451        if gist_id.is_empty() {
452            return Err(GitHubError::invalid_input("Gist ID cannot be empty"));
453        }
454
455        let url = self.generate_gist_url(&gist_id);
456
457        Ok(Gist {
458            id: gist_id,
459            url,
460            files: HashMap::new(),
461            description: String::new(),
462            public: true,
463        })
464    }
465
466    /// Helper function to generate a gist ID
467    fn generate_gist_id(&self) -> String {
468        use std::time::{SystemTime, UNIX_EPOCH};
469
470        let duration = SystemTime::now()
471            .duration_since(UNIX_EPOCH)
472            .unwrap_or_default();
473
474        format!("{:x}", duration.as_nanos())
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn test_gist_options_default() {
484        let opts = GistOptions::default();
485        assert_eq!(opts.description, "");
486        assert!(opts.public);
487        assert!(opts.tags.is_empty());
488    }
489
490    #[test]
491    fn test_gist_options_builder() {
492        let opts = GistOptions::new("Test gist")
493            .with_public(false)
494            .with_tag("rust")
495            .with_tag("example")
496            .with_category("snippet");
497
498        assert_eq!(opts.description, "Test gist");
499        assert!(!opts.public);
500        assert_eq!(opts.tags.len(), 2);
501        assert_eq!(opts.category, Some("snippet".to_string()));
502    }
503
504    #[test]
505    fn test_gist_manager_creation() {
506        let manager = GistManager::new("token123", "testuser");
507        assert_eq!(manager.token, "token123");
508        assert_eq!(manager.username, "testuser");
509    }
510
511    #[test]
512    fn test_generate_gist_url() {
513        let manager = GistManager::new("token", "testuser");
514        let url = manager.generate_gist_url("abc123");
515        assert_eq!(url, "https://gist.github.com/testuser/abc123");
516    }
517
518    #[tokio::test]
519    async fn test_create_gist_empty_filename() {
520        let manager = GistManager::new("token", "testuser");
521        let result = manager
522            .create_gist("", "content", None, GistOptions::default())
523            .await;
524
525        assert!(result.is_err());
526        match result {
527            Err(GitHubError::InvalidInput(msg)) => {
528                assert!(msg.contains("Filename cannot be empty"));
529            }
530            _ => panic!("Expected InvalidInput error"),
531        }
532    }
533
534    #[tokio::test]
535    async fn test_create_gist_empty_content() {
536        let manager = GistManager::new("token", "testuser");
537        let result = manager
538            .create_gist("test.rs", "", None, GistOptions::default())
539            .await;
540
541        assert!(result.is_err());
542        match result {
543            Err(GitHubError::InvalidInput(msg)) => {
544                assert!(msg.contains("Content cannot be empty"));
545            }
546            _ => panic!("Expected InvalidInput error"),
547        }
548    }
549
550    #[tokio::test]
551    async fn test_create_gist_success() {
552        let manager = GistManager::new("token", "testuser");
553        let result = manager
554            .create_gist("test.rs", "fn main() {}", Some("rust".to_string()), GistOptions::default())
555            .await;
556
557        assert!(result.is_ok());
558        let gist = result.unwrap();
559        assert!(!gist.gist_id.is_empty());
560        assert!(gist.url.contains("https://gist.github.com/testuser/"));
561    }
562
563    #[tokio::test]
564    async fn test_update_gist_empty_id() {
565        let manager = GistManager::new("token", "testuser");
566        let result = manager
567            .update_gist("", "test.rs", "content", None)
568            .await;
569
570        assert!(result.is_err());
571    }
572
573    #[tokio::test]
574    async fn test_delete_gist_empty_id() {
575        let manager = GistManager::new("token", "testuser");
576        let result = manager.delete_gist("").await;
577
578        assert!(result.is_err());
579    }
580
581    #[tokio::test]
582    async fn test_delete_gist_success() {
583        let manager = GistManager::new("token", "testuser");
584        let result = manager.delete_gist("abc123").await;
585
586        assert!(result.is_ok());
587        let lifecycle = result.unwrap();
588        assert_eq!(lifecycle.gist_id, "abc123");
589        assert_eq!(lifecycle.operation, "delete");
590        assert!(lifecycle.success);
591    }
592
593    #[tokio::test]
594    async fn test_archive_gist_success() {
595        let manager = GistManager::new("token", "testuser");
596        let result = manager.archive_gist("abc123").await;
597
598        assert!(result.is_ok());
599        let lifecycle = result.unwrap();
600        assert_eq!(lifecycle.gist_id, "abc123");
601        assert_eq!(lifecycle.operation, "archive");
602        assert!(lifecycle.success);
603    }
604
605    #[tokio::test]
606    async fn test_set_gist_visibility() {
607        let manager = GistManager::new("token", "testuser");
608        let result = manager.set_gist_visibility("abc123", false).await;
609
610        assert!(result.is_ok());
611        let gist = result.unwrap();
612        assert_eq!(gist.id, "abc123");
613        assert!(!gist.public);
614    }
615
616    #[tokio::test]
617    async fn test_restore_gist_success() {
618        let manager = GistManager::new("token", "testuser");
619        let result = manager.restore_gist("abc123").await;
620
621        assert!(result.is_ok());
622        let lifecycle = result.unwrap();
623        assert_eq!(lifecycle.gist_id, "abc123");
624        assert_eq!(lifecycle.operation, "restore");
625        assert!(lifecycle.success);
626    }
627
628    #[tokio::test]
629    async fn test_restore_gist_empty_id() {
630        let manager = GistManager::new("token", "testuser");
631        let result = manager.restore_gist("").await;
632
633        assert!(result.is_err());
634    }
635
636    #[tokio::test]
637    async fn test_list_gists() {
638        let manager = GistManager::new("token", "testuser");
639        let result = manager.list_gists().await;
640
641        assert!(result.is_ok());
642        let gists = result.unwrap();
643        assert!(gists.is_empty()); // Empty for now
644    }
645
646    #[tokio::test]
647    async fn test_get_gist_success() {
648        let manager = GistManager::new("token", "testuser");
649        let result = manager.get_gist("abc123").await;
650
651        assert!(result.is_ok());
652        let gist = result.unwrap();
653        assert_eq!(gist.id, "abc123");
654        assert!(gist.url.contains("abc123"));
655    }
656
657    #[tokio::test]
658    async fn test_get_gist_empty_id() {
659        let manager = GistManager::new("token", "testuser");
660        let result = manager.get_gist("").await;
661
662        assert!(result.is_err());
663    }
664}