pipitor 0.2.2

A Twitter bot that gathers, filters and Retweets Tweets automatically.
Documentation
use serde::ser::{SerializeMap, Serializer};
use serde::{de, Deserialize, Serialize};

use crate::util::replace_char_range;

#[derive(Clone, Debug, Serialize)]
pub struct Tweet {
    pub id: i64,
    pub text: Box<str>,
    pub in_reply_to_status_id: Option<i64>,
    pub in_reply_to_user_id: Option<i64>,
    pub user: User,
    pub quoted_status: Option<QuotedStatus>,
    #[serde(serialize_with = "ser_retweeted_status")]
    pub retweeted_status: Option<de::IgnoredAny>,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct User {
    pub id: i64,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct QuotedStatus {
    pub id: i64,
}

#[derive(Deserialize)]
struct Entities {
    #[serde(default)]
    urls: Vec<Url>,
    #[serde(default)]
    media: Vec<Url>,
}

#[derive(Deserialize)]
struct Url {
    expanded_url: String,
    indices: (usize, usize),
}

impl<'de> Deserialize<'de> for Tweet {
    fn deserialize<D: de::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        #[derive(Deserialize)]
        struct Prototype {
            id: i64,
            #[serde(alias = "full_text")]
            text: String,
            #[serde(default)]
            in_reply_to_status_id: Option<i64>,
            #[serde(default)]
            in_reply_to_user_id: Option<i64>,
            user: User,
            extended_tweet: Option<ExtendedTweet>,
            #[serde(default)]
            quoted_status: Option<QuotedStatus>,
            #[serde(default)]
            retweeted_status: Option<de::IgnoredAny>,
            #[serde(default)]
            entities: Option<Entities>,
        }

        #[derive(Deserialize)]
        struct ExtendedTweet {
            full_text: String,
            #[serde(default)]
            entities: Option<Entities>,
        }

        Prototype::deserialize(d).map(|p| {
            let (text, entities) = (p.text, p.entities);
            let text = p.extended_tweet.map_or_else(
                || expand_urls(text, entities),
                |e| expand_urls(e.full_text, e.entities),
            )?;
            Ok(Tweet {
                id: p.id,
                text,
                in_reply_to_status_id: p.in_reply_to_status_id,
                in_reply_to_user_id: p.in_reply_to_user_id,
                user: p.user,
                quoted_status: p.quoted_status,
                retweeted_status: p.retweeted_status,
            })
        })?
    }
}

fn ser_retweeted_status<S>(rt: &Option<de::IgnoredAny>, s: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    if rt.is_some() {
        let map = s.serialize_map(Some(0))?;
        map.end()
    } else {
        s.serialize_unit()
    }
}

fn expand_urls<E>(mut text: String, entities: Option<Entities>) -> Result<Box<str>, E>
where
    E: de::Error,
{
    let mut e = if let Some(e) = entities {
        e
    } else {
        return Ok(text.into_boxed_str());
    };

    let mut urls = if e.urls.len() > e.media.len() {
        e.urls.extend(e.media);
        e.urls
    } else {
        e.media.extend(e.urls);
        e.media
    };
    urls.sort_by_key(|u| u.indices.0);

    let (mut byte_offset, mut char_offset) = (0, 0);
    for url in urls {
        let Url {
            indices: (s, e),
            expanded_url,
        } = url;

        if s > e {
            return Err(E::custom("invalid indices (start > end)"));
        }

        if e < byte_offset {
            return Err(E::custom("url indices overlap"));
        }

        let range = (s - char_offset)..(e - char_offset);
        if let Some(i) = replace_char_range(&mut text, byte_offset, range, &expanded_url) {
            byte_offset = i + expanded_url.len();
            char_offset = e;
        } else {
            return Err(E::custom("url indices out of range"));
        }
    }

    Ok(text.into_boxed_str())
}

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

    #[test]
    fn de_tweet() {
        let tweet: Tweet =
            json::from_str(include_str!("testcases/status_912783930431905797.json")).unwrap();
        let expected_text = "Can’t fit your Tweet into 140 characters? 🤔\n\nWe’re trying something new with a small group, and increasing the character limit to 280! Excited about the possibilities? Read our blog to find out how it all adds up. 👇\nhttps://cards.twitter.com/cards/gsby/4ubsj";
        match tweet {
            Tweet {
                id: 912783930431905797,
                ref text,
                in_reply_to_status_id: None,
                in_reply_to_user_id: None,
                user: User { id: 783214 },
                quoted_status: None,
                retweeted_status: None,
            } if **text == *expected_text => {}
            _ => panic!("tweet: {:#?}", tweet),
        }
    }
}