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 }