entry.rs (6942B)
1 use chrono::{DateTime, Duration, FixedOffset, Local, SecondsFormat}; 2 3 use std::fmt; 4 5 #[derive(Ord, PartialOrd, PartialEq, Eq, Debug)] 6 pub struct Entry { 7 timestamp: DateTime<FixedOffset>, 8 author: Option<String>, 9 content: String, 10 } 11 12 impl Entry { 13 /// Generates a new entry from given content. 14 pub fn new(content: String, author: Option<String>) -> Entry { 15 Entry { 16 timestamp: Self::now(), 17 author, 18 content, 19 } 20 } 21 22 /// Attempt to parse given line into entry format. 23 pub fn parse(line: &str, author: Option<&str>) -> Result<Entry, ()> { 24 if let Some(seperator_idx) = line.find('\t') { 25 if let Ok(timestamp) = DateTime::parse_from_rfc3339(&line[..seperator_idx]) { 26 Ok(Entry { 27 timestamp, 28 author: author.and_then(|x| Some(x.to_owned())), 29 content: line[seperator_idx + 1..].to_owned(), 30 }) 31 } else { 32 Err(()) 33 } 34 } else { 35 Err(()) 36 } 37 } 38 39 /// Generates string representation per twtxt spec for given entry. 40 pub fn to_twtxt(&self) -> String { 41 format!( 42 "{}\t{}\n", 43 &self.timestamp.to_rfc3339_opts(SecondsFormat::Secs, true), 44 &self.content 45 ) 46 } 47 48 /// Formats a time duration in human readable format. 49 /// Shows the first non-zero amount of year, month, week, day, hour, or 50 /// minute. For duration shorter than one minute, return "just now". 51 fn format_duration(duration: Duration) -> String { 52 let (num, unit) = if duration.num_days() >= 365 { 53 (duration.num_days() / 365, "year") 54 } else if duration.num_days() >= 30 { 55 (duration.num_days() / 30, "month") 56 } else if duration.num_weeks() >= 1 { 57 (duration.num_weeks(), "week") 58 } else if duration.num_days() >= 1 { 59 (duration.num_days(), "day") 60 } else if duration.num_hours() >= 1 { 61 (duration.num_hours(), "hour") 62 } else if duration.num_minutes() >= 1 { 63 (duration.num_minutes(), "minute") 64 } else { 65 return "just now".to_string(); 66 }; 67 68 if num > 1 { 69 format!("{} {}s ago", num, unit) 70 } else { 71 format!("{} {} ago", num, unit) 72 } 73 } 74 75 /// Get local time now as DateTime<FixedOffset>. 76 fn now() -> DateTime<FixedOffset> { 77 let local = Local::now(); 78 local.with_timezone(local.offset()) 79 } 80 } 81 82 impl fmt::Display for Entry { 83 /// Formats a tweet for display in terminal. 84 /// Alternate format uses absolute time. 85 fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 86 let timestamp = if formatter.alternate() { 87 Self::format_duration(Local::now() - self.timestamp.with_timezone(&Local)) 88 } else { 89 self.timestamp 90 .with_timezone(&Local) 91 .to_rfc3339_opts(SecondsFormat::Secs, true) 92 }; 93 94 write!( 95 formatter, 96 "@{} {}\n{}", 97 self.author.as_ref().unwrap_or(&"".to_string()), 98 ×tamp, 99 &self.content 100 ) 101 } 102 } 103 104 #[cfg(test)] 105 mod tests { 106 use super::*; 107 108 use chrono::offset::TimeZone; 109 110 #[test] 111 fn test_new() { 112 let start: DateTime<FixedOffset> = Entry::now(); 113 let entry = Entry::new("content".to_string(), Some("author".to_string())); 114 let end: DateTime<FixedOffset> = Entry::now(); 115 116 assert_eq!(start.cmp(&entry.timestamp), std::cmp::Ordering::Less); 117 assert_eq!(end.cmp(&entry.timestamp), std::cmp::Ordering::Greater); 118 assert_eq!(entry.content, "content"); 119 assert_eq!(entry.author, Some("author".to_string())); 120 } 121 122 #[test] 123 fn test_parse() { 124 assert_eq!(Entry::parse("This is not valid twtxt.\t", None), Err(())); 125 assert_eq!( 126 Entry::parse("2016-02-04T13:30:01+01:00\tThis is valid twtxt.", None), 127 Ok(Entry { 128 timestamp: FixedOffset::east(3600).ymd(2016, 02, 04).and_hms(13, 30, 1), 129 author: None, 130 content: "This is valid twtxt.".to_string(), 131 }) 132 ); 133 } 134 135 #[test] 136 fn test_to_twtxt() { 137 assert_eq!( 138 Entry { 139 timestamp: FixedOffset::east(3600) 140 .ymd(2016, 02, 04) 141 .and_hms_milli(13, 30, 1, 238), 142 author: None, 143 content: "Hello world!".to_string(), 144 } 145 .to_twtxt(), 146 "2016-02-04T13:30:01+01:00\tHello world!\n" 147 ); 148 } 149 150 #[test] 151 fn test_format_duration() { 152 assert_eq!( 153 Entry::format_duration(Duration::days(365 * 2)), 154 "2 years ago" 155 ); 156 assert_eq!(Entry::format_duration(Duration::days(365)), "1 year ago"); 157 assert_eq!( 158 Entry::format_duration(Duration::days(30 * 3)), 159 "3 months ago" 160 ); 161 assert_eq!(Entry::format_duration(Duration::days(30)), "1 month ago"); 162 assert_eq!(Entry::format_duration(Duration::weeks(4)), "4 weeks ago"); 163 assert_eq!(Entry::format_duration(Duration::weeks(1)), "1 week ago"); 164 assert_eq!(Entry::format_duration(Duration::days(4)), "4 days ago"); 165 assert_eq!(Entry::format_duration(Duration::days(1)), "1 day ago"); 166 assert_eq!(Entry::format_duration(Duration::hours(23)), "23 hours ago"); 167 assert_eq!(Entry::format_duration(Duration::hours(1)), "1 hour ago"); 168 assert_eq!( 169 Entry::format_duration(Duration::minutes(5)), 170 "5 minutes ago" 171 ); 172 assert_eq!(Entry::format_duration(Duration::minutes(1)), "1 minute ago"); 173 assert_eq!(Entry::format_duration(Duration::seconds(30)), "just now"); 174 } 175 176 #[test] 177 fn test_now() { 178 let to_fixed_offset = |x: &DateTime<Local>| x.with_timezone(x.offset()); 179 180 let start = to_fixed_offset(&Local::now()); 181 let timestamp = Entry::now(); 182 let end = to_fixed_offset(&Local::now()); 183 184 assert_eq!(timestamp.offset(), Local::now().offset()); 185 assert_eq!(start.cmp(×tamp), std::cmp::Ordering::Less); 186 assert_eq!(end.cmp(×tamp), std::cmp::Ordering::Greater); 187 } 188 189 #[test] 190 fn test_display() { 191 let timestamp = Entry::now() - Duration::weeks(1); 192 let entry = Entry { 193 timestamp, 194 author: Some("anonymous".to_string()), 195 content: "Hello world!".to_string(), 196 }; 197 198 assert_eq!( 199 format!("{:#}", entry), 200 "@anonymous 1 week ago\nHello world!" 201 ); 202 203 assert_eq!( 204 format!("{}", entry), 205 format!( 206 "@anonymous {}\nHello world!", 207 timestamp.to_rfc3339_opts(SecondsFormat::Secs, true) 208 ) 209 ); 210 } 211 }