twixter

A twtxt command line client in Rust

git clone git://git.shimmy1996.com/twixter.git
commit d809bdb9cff509fa60d941b61fc7912e8b88eb9e
parent 955a77a99bf88e9aa45232372803379913e58189
Author: Shimmy Xu <shimmy.xu@shimmy1996.com>
Date:   Fri,  4 Oct 2019 12:04:26 -0400

Use a separate Entry struct for all the parsing logic

Diffstat:
Asrc/entry.rs | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 1+
Msrc/timeline.rs | 93+++++++++++--------------------------------------------------------------------
Msrc/tweet.rs | 15++-------------
4 files changed, 142 insertions(+), 94 deletions(-)
diff --git a/src/entry.rs b/src/entry.rs
@@ -0,0 +1,127 @@
+use chrono::{DateTime, Duration, FixedOffset, Local, SecondsFormat};
+
+use std::fmt;
+
+#[derive(Ord, PartialOrd, PartialEq, Eq)]
+pub struct Entry {
+    pub timestamp: DateTime<FixedOffset>,
+    pub author: Option<String>,
+    pub content: String,
+}
+
+impl Entry {
+    /// Generates a new entry from given content.
+    pub fn new(content: String, author: Option<String>) -> Entry {
+        Entry {
+            timestamp: Local::now().into(),
+            author,
+            content,
+        }
+    }
+
+    /// Attempt to parse given line into entry format.
+    pub fn parse(line: &str, author: Option<&str>) -> Result<Entry, ()> {
+        if let Some(seperator_idx) = line.find('\t') {
+            if let Ok(timestamp) = DateTime::parse_from_rfc3339(&line[..seperator_idx]) {
+                Ok(Entry {
+                    timestamp,
+                    author: author.and_then(|x| Some(x.to_owned())),
+                    content: line[seperator_idx + 1..].to_owned(),
+                })
+            } else {
+                Err(())
+            }
+        } else {
+            Err(())
+        }
+    }
+
+    /// Generates spec representation for given entry.
+    pub fn to_string(&self) -> String {
+        format!(
+            "{}\t{}\n",
+            &self.timestamp.to_rfc3339_opts(SecondsFormat::Secs, true),
+            &self.content
+        )
+    }
+
+    /// Formats a time duration in human readable format.
+    /// Shows the first non-zero amount of year, month, week, day, hour, or
+    /// minute. For duration shorter than one minute, return "just now".
+    fn format_duration(duration: Duration) -> String {
+        let (num, unit) = if duration.num_days() >= 365 {
+            (duration.num_days() / 365, "year")
+        } else if duration.num_days() >= 30 {
+            (duration.num_days() / 30, "month")
+        } else if duration.num_weeks() >= 1 {
+            (duration.num_weeks(), "week")
+        } else if duration.num_days() >= 1 {
+            (duration.num_days(), "day")
+        } else if duration.num_hours() >= 1 {
+            (duration.num_hours(), "hour")
+        } else if duration.num_minutes() >= 1 {
+            (duration.num_minutes(), "minute")
+        } else {
+            return "just now".to_string();
+        };
+
+        if num > 1 {
+            format!("{} {}s ago", num, unit)
+        } else {
+            format!("{} {} ago", num, unit)
+        }
+    }
+}
+
+impl fmt::Display for Entry {
+    /// Formats a tweet for display in terminal.
+    /// Alternate format uses absolute time.
+    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+        let timestamp = if formatter.alternate() {
+            self.timestamp
+                .with_timezone(&Local)
+                .to_rfc3339_opts(SecondsFormat::Secs, true)
+        } else {
+            Self::format_duration(Local::now() - self.timestamp.with_timezone(&Local))
+        };
+
+        write!(
+            formatter,
+            "\n@{} {}\n{}",
+            self.author.as_ref().unwrap_or(&"".to_string()),
+            &timestamp,
+            &self.content
+        )
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_format_duration() {
+        assert_eq!(
+            Entry::format_duration(Duration::days(365 * 2)),
+            "2 years ago"
+        );
+        assert_eq!(Entry::format_duration(Duration::days(365)), "1 year ago");
+        assert_eq!(
+            Entry::format_duration(Duration::days(30 * 3)),
+            "3 months ago"
+        );
+        assert_eq!(Entry::format_duration(Duration::days(30)), "1 month ago");
+        assert_eq!(Entry::format_duration(Duration::weeks(4)), "4 weeks ago");
+        assert_eq!(Entry::format_duration(Duration::weeks(1)), "1 week ago");
+        assert_eq!(Entry::format_duration(Duration::days(4)), "4 days ago");
+        assert_eq!(Entry::format_duration(Duration::days(1)), "1 day ago");
+        assert_eq!(Entry::format_duration(Duration::hours(23)), "23 hours ago");
+        assert_eq!(Entry::format_duration(Duration::hours(1)), "1 hour ago");
+        assert_eq!(
+            Entry::format_duration(Duration::minutes(5)),
+            "5 minutes ago"
+        );
+        assert_eq!(Entry::format_duration(Duration::minutes(1)), "1 minute ago");
+        assert_eq!(Entry::format_duration(Duration::seconds(30)), "just now");
+    }
+}
diff --git a/src/main.rs b/src/main.rs
@@ -3,6 +3,7 @@ use clap::{crate_version, App, Arg, SubCommand};
 use std::path::Path;
 
 mod config;
+mod entry;
 mod follow;
 mod timeline;
 mod tweet;
diff --git a/src/timeline.rs b/src/timeline.rs
@@ -1,15 +1,15 @@
-use chrono::{DateTime, Duration, FixedOffset, Local, SecondsFormat};
 use clap::ArgMatches;
 use reqwest::Client;
 
 use std::collections::BinaryHeap;
 
 use crate::config::Config;
+use crate::entry::Entry;
 
 /// Print timeline from following.
 pub fn timeline(config: &Config, _subcommand: &ArgMatches) {
     // Store (post_time, nick, content).
-    let mut all_tweets = BinaryHeap::<(DateTime<FixedOffset>, String, String)>::new();
+    let mut all_tweets = BinaryHeap::<Entry>::new();
 
     // Pull and parse twtxt files from user and each followed source.
     for (nick, twturl) in config
@@ -17,102 +17,33 @@ pub fn timeline(config: &Config, _subcommand: &ArgMatches) {
         .iter()
         .chain(vec![(&config.nick, &config.twturl)].into_iter())
     {
-        let tweets = parse_twtxt(twturl);
-        for (post_time, content) in tweets {
-            all_tweets.push((post_time, nick.to_owned(), content));
-        }
+        all_tweets.append(&mut parse_twtxt(twturl, nick));
     }
 
     // Print the most recent tweets.
-    let now = Local::now();
     for _ in 0..config.limit_timeline {
         if let Some(tweet) = all_tweets.pop() {
-            println!("{}", format_tweet(&tweet, &now, config.use_abs_time));
+            if config.use_abs_time {
+                println!("{:#}", tweet);
+            } else {
+                println!("{}", tweet);
+            }
         }
     }
 }
 
 /// Parses given twtxt url, returns a Vec of (post_time, content).
-fn parse_twtxt(twturl: &str) -> Vec<(DateTime<FixedOffset>, String)> {
+fn parse_twtxt(twturl: &str, nick: &str) -> BinaryHeap<Entry> {
     let client = Client::new();
-    let mut tweets = Vec::new();
+    let mut tweets = BinaryHeap::new();
 
     if let Ok(resp_text) = client.get(twturl).send().and_then(|mut resp| resp.text()) {
         for line in resp_text.lines() {
-            if let Some(seperator_idx) = line.find('\t') {
-                if let Ok(tweet_time) = DateTime::parse_from_rfc3339(&line[..seperator_idx]) {
-                    tweets.push((tweet_time, line[seperator_idx + 1..].to_owned()));
-                };
+            if let Ok(parsed) = Entry::parse(line, Some(nick)) {
+                tweets.push(parsed);
             };
         }
     };
 
     tweets
 }
-
-/// Formats a tweet for display in terminal.
-fn format_tweet(
-    tweet: &(DateTime<FixedOffset>, String, String),
-    now: &DateTime<Local>,
-    use_abs_time: bool,
-) -> String {
-    let timestamp = if use_abs_time {
-        tweet
-            .0
-            .with_timezone(&now.timezone())
-            .to_rfc3339_opts(SecondsFormat::Secs, true)
-    } else {
-        format_duration(*now - tweet.0.with_timezone(&now.timezone()))
-    };
-
-    format!("\n@{} {}\n{}", &tweet.1, &timestamp, &tweet.2)
-}
-
-/// Formats a time duration in human readable format.
-/// Shows the first non-zero amount of year, month, week, day, hour, or
-/// minute. For duration shorter than one minute, return "just now".
-fn format_duration(duration: Duration) -> String {
-    let (num, unit) = if duration.num_days() >= 365 {
-        (duration.num_days() / 365, "year")
-    } else if duration.num_days() >= 30 {
-        (duration.num_days() / 30, "month")
-    } else if duration.num_weeks() >= 1 {
-        (duration.num_weeks(), "week")
-    } else if duration.num_days() >= 1 {
-        (duration.num_days(), "day")
-    } else if duration.num_hours() >= 1 {
-        (duration.num_hours(), "hour")
-    } else if duration.num_minutes() >= 1 {
-        (duration.num_minutes(), "minute")
-    } else {
-        return "just now".to_string();
-    };
-
-    if num > 1 {
-        format!("{} {}s ago", num, unit)
-    } else {
-        format!("{} {} ago", num, unit)
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn test_format_duration() {
-        assert_eq!(format_duration(Duration::days(365 * 2)), "2 years ago");
-        assert_eq!(format_duration(Duration::days(365)), "1 year ago");
-        assert_eq!(format_duration(Duration::days(30 * 3)), "3 months ago");
-        assert_eq!(format_duration(Duration::days(30)), "1 month ago");
-        assert_eq!(format_duration(Duration::weeks(4)), "4 weeks ago");
-        assert_eq!(format_duration(Duration::weeks(1)), "1 week ago");
-        assert_eq!(format_duration(Duration::days(4)), "4 days ago");
-        assert_eq!(format_duration(Duration::days(1)), "1 day ago");
-        assert_eq!(format_duration(Duration::hours(23)), "23 hours ago");
-        assert_eq!(format_duration(Duration::hours(1)), "1 hour ago");
-        assert_eq!(format_duration(Duration::minutes(5)), "5 minutes ago");
-        assert_eq!(format_duration(Duration::minutes(1)), "1 minute ago");
-        assert_eq!(format_duration(Duration::seconds(30)), "just now");
-    }
-}
diff --git a/src/tweet.rs b/src/tweet.rs
@@ -1,4 +1,3 @@
-use chrono::{Local, SecondsFormat};
 use clap::ArgMatches;
 
 use std::fs::OpenOptions;
@@ -7,6 +6,7 @@ use std::path::Path;
 use std::process::Command;
 
 use crate::config::Config;
+use crate::entry::Entry;
 
 /// Helper to run the tweet subcommand.
 pub fn tweet(config: &Config, subcommand: &ArgMatches) {
@@ -41,7 +41,7 @@ pub fn tweet(config: &Config, subcommand: &ArgMatches) {
             .create(true)
             .open(Path::new(&config.twtfile))
             .unwrap()
-            .write(compose(content).as_bytes())
+            .write(Entry::new(content, None).to_string().as_bytes())
             .expect("Unable to write new post");
 
         // Run post tweet hook.
@@ -51,14 +51,3 @@ pub fn tweet(config: &Config, subcommand: &ArgMatches) {
             .expect("Failed to run post tweet hook");
     }
 }
-
-/// Formats given content into twtxt format by adding datetime.
-fn compose(content: String) -> String {
-    let timestamp = Local::now().to_rfc3339_opts(SecondsFormat::Secs, true);
-    let mut post = String::new();
-    post.push_str(&timestamp);
-    post.push('\t');
-    post.push_str(&content);
-    post.push('\n');
-    post
-}