molten-herald 0.1.0

Automated viral tweet generation and scheduling for developer releases 📢
Documentation
//! Event detection for releases, commits, and other announcements

use crate::config::{EventType, ProjectConfig};
use crate::error::{HeraldError, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// An event that can be announced
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
    /// Unique identifier
    pub id: String,

    /// Event type
    pub event_type: EventType,

    /// Project name
    pub project: String,

    /// Event title/summary
    pub title: String,

    /// Detailed description
    pub description: Option<String>,

    /// Version (for releases)
    pub version: Option<String>,

    /// URL to the event (release page, PR, etc.)
    pub url: Option<String>,

    /// Timestamp
    pub timestamp: DateTime<Utc>,

    /// Author/contributor
    pub author: Option<String>,

    /// Additional context
    #[serde(default)]
    pub context: EventContext,
}

/// Additional context for event generation
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EventContext {
    /// Key features or changes
    #[serde(default)]
    pub highlights: Vec<String>,

    /// Breaking changes
    #[serde(default)]
    pub breaking_changes: Vec<String>,

    /// Contributors
    #[serde(default)]
    pub contributors: Vec<String>,

    /// Statistics (downloads, stars, etc.)
    #[serde(default)]
    pub stats: Option<EventStats>,

    /// Related links
    #[serde(default)]
    pub links: Vec<EventLink>,

    /// Tags/labels
    #[serde(default)]
    pub tags: Vec<String>,
}

/// Statistics for an event
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventStats {
    /// Download count
    pub downloads: Option<u64>,

    /// Star count
    pub stars: Option<u64>,

    /// Fork count
    pub forks: Option<u64>,

    /// Lines changed
    pub lines_changed: Option<i64>,

    /// Files changed
    pub files_changed: Option<u32>,
}

/// A related link
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventLink {
    /// Link title
    pub title: String,

    /// URL
    pub url: String,
}

/// Event detector for finding announceworthy events
pub struct EventDetector {
    client: reqwest::Client,
    github_token: Option<String>,
}

impl EventDetector {
    /// Create a new event detector
    pub fn new() -> Self {
        Self {
            client: reqwest::Client::new(),
            github_token: std::env::var("GITHUB_TOKEN").ok(),
        }
    }

    /// Create with a specific GitHub token
    pub fn with_github_token(token: String) -> Self {
        Self {
            client: reqwest::Client::new(),
            github_token: Some(token),
        }
    }

    /// Detect events for a project
    pub async fn detect(&self, project: &ProjectConfig) -> Result<Vec<Event>> {
        let mut events = Vec::new();

        // Check GitHub releases
        if let Some(ref repo) = project.github {
            if project.events.contains(&EventType::Release) {
                if let Ok(releases) = self.get_github_releases(repo).await {
                    events.extend(releases);
                }
            }
        }

        // Check crates.io for new versions
        if let Some(ref crate_name) = project.crates_io {
            if let Ok(crate_events) = self.get_crates_io_releases(crate_name, &project.name).await {
                events.extend(crate_events);
            }
        }

        Ok(events)
    }

    /// Get recent GitHub releases
    async fn get_github_releases(&self, repo: &str) -> Result<Vec<Event>> {
        let url = format!("https://api.github.com/repos/{}/releases?per_page=5", repo);
        
        let mut request = self.client
            .get(&url)
            .header("User-Agent", "herald-cli")
            .header("Accept", "application/vnd.github.v3+json");

        if let Some(ref token) = self.github_token {
            request = request.header("Authorization", format!("Bearer {}", token));
        }

        let response = request.send().await?;
        
        if !response.status().is_success() {
            return Err(HeraldError::Git(format!(
                "GitHub API error: {}",
                response.status()
            )));
        }

        let releases: Vec<GitHubRelease> = response.json().await?;
        
        Ok(releases
            .into_iter()
            .map(|r| Event {
                id: r.id.to_string(),
                event_type: EventType::Release,
                project: repo.split('/').last().unwrap_or(repo).to_string(),
                title: r.name.unwrap_or_else(|| r.tag_name.clone()),
                description: r.body,
                version: Some(r.tag_name),
                url: Some(r.html_url),
                timestamp: r.published_at.unwrap_or_else(Utc::now),
                author: r.author.map(|a| a.login),
                context: EventContext::default(),
            })
            .collect())
    }

    /// Get recent crates.io versions
    async fn get_crates_io_releases(&self, crate_name: &str, project_name: &str) -> Result<Vec<Event>> {
        let url = format!("https://crates.io/api/v1/crates/{}/versions", crate_name);
        
        let response = self.client
            .get(&url)
            .header("User-Agent", "herald-cli (https://github.com/moltenlabs/herald)")
            .send()
            .await?;

        if !response.status().is_success() {
            return Err(HeraldError::Config(format!(
                "crates.io API error: {}",
                response.status()
            )));
        }

        let data: CratesIoResponse = response.json().await?;
        
        Ok(data.versions
            .into_iter()
            .take(3)
            .map(|v| Event {
                id: format!("crates-{}-{}", crate_name, v.num),
                event_type: EventType::Release,
                project: project_name.to_string(),
                title: format!("{} v{}", crate_name, v.num),
                description: None,
                version: Some(v.num.clone()),
                url: Some(format!("https://crates.io/crates/{}/{}", crate_name, v.num)),
                timestamp: v.created_at,
                author: None,
                context: EventContext {
                    stats: Some(EventStats {
                        downloads: Some(v.downloads),
                        stars: None,
                        forks: None,
                        lines_changed: None,
                        files_changed: None,
                    }),
                    ..Default::default()
                },
            })
            .collect())
    }

    /// Create a manual event for immediate announcement
    pub fn create_manual_event(
        project: &str,
        title: &str,
        description: Option<&str>,
        url: Option<&str>,
        event_type: EventType,
    ) -> Event {
        Event {
            id: uuid::Uuid::new_v4().to_string(),
            event_type,
            project: project.to_string(),
            title: title.to_string(),
            description: description.map(String::from),
            version: None,
            url: url.map(String::from),
            timestamp: Utc::now(),
            author: None,
            context: EventContext::default(),
        }
    }
}

impl Default for EventDetector {
    fn default() -> Self {
        Self::new()
    }
}

/// GitHub release from API
#[derive(Debug, Deserialize)]
struct GitHubRelease {
    id: u64,
    tag_name: String,
    name: Option<String>,
    body: Option<String>,
    html_url: String,
    published_at: Option<DateTime<Utc>>,
    author: Option<GitHubUser>,
}

#[derive(Debug, Deserialize)]
struct GitHubUser {
    login: String,
}

/// Crates.io API response
#[derive(Debug, Deserialize)]
struct CratesIoResponse {
    versions: Vec<CratesIoVersion>,
}

#[derive(Debug, Deserialize)]
struct CratesIoVersion {
    num: String,
    downloads: u64,
    created_at: DateTime<Utc>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_create_manual_event() {
        let event = EventDetector::create_manual_event(
            "herald",
            "Herald v0.1.0 released!",
            Some("Automated tweet generation for developers"),
            Some("https://github.com/moltenlabs/herald"),
            EventType::Release,
        );

        assert_eq!(event.project, "herald");
        assert_eq!(event.event_type, EventType::Release);
        assert!(event.url.is_some());
    }

    #[test]
    fn test_event_serialization() {
        let event = Event {
            id: "test-123".to_string(),
            event_type: EventType::Release,
            project: "test".to_string(),
            title: "Test Release".to_string(),
            description: None,
            version: Some("1.0.0".to_string()),
            url: None,
            timestamp: Utc::now(),
            author: None,
            context: EventContext::default(),
        };

        let json = serde_json::to_string(&event).unwrap();
        let parsed: Event = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.id, "test-123");
    }

    #[test]
    fn test_event_context() {
        let context = EventContext {
            highlights: vec!["Fast".to_string(), "Safe".to_string()],
            breaking_changes: vec![],
            contributors: vec!["alice".to_string()],
            stats: Some(EventStats {
                downloads: Some(1000),
                stars: Some(50),
                forks: None,
                lines_changed: None,
                files_changed: None,
            }),
            links: vec![],
            tags: vec!["rust".to_string()],
        };

        assert_eq!(context.highlights.len(), 2);
        assert!(context.stats.is_some());
    }
}