timelog/
entry.rs

1//! Module representing an entry in the timelog.
2//!
3//! # Examples
4//!
5//! ```rust
6//! use timelog::entry::Entry;
7//! use std::fs::File;
8//! use std::io::{BufRead, BufReader};
9//!
10//! fn day_entrys(date: &str, file: &mut File) -> Vec<Entry> {
11//!     let mut reader = BufReader::new(file);
12//!     reader.lines()
13//!           .filter_map(|line| Entry::from_line(&line.ok()?).ok())
14//!           .filter(|ev| ev.stamp() == String::from(date))
15//!           .collect::<Vec<Entry>>()
16//! }
17//! ```
18//!
19//! # Description
20//!
21//! Objects of this type represent the individual lines in the `timelog.txt` file.
22//! Each [`Entry`] has a date and time stamp, an optional project, and a task.
23
24use std::fmt::{self, Debug, Display};
25
26use once_cell::sync::Lazy;
27use regex::Regex;
28
29const STOP_CMD: &str = "stop";
30
31// These should not be able to fail, hardcoded input strings.
32// Still using expect() in case the regex strings ever get changed.
33
34/// Regular expression to match a time stamp
35static TIMESTAMP_RE: Lazy<Regex> = Lazy::new(|| {
36    Regex::new(r"\A(\d{4}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12][0-9]|3[01]) (?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-6][0-9])").expect("Date time Regex failed.")
37});
38/// A somewhat lax regular expression to match an entry line.
39static LAX_LINE_RE: Lazy<Regex> = Lazy::new(|| {
40    Regex::new(r"\A(\d{4}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12][0-9]|3[01]) (?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-6][0-9])(.)(.+)").expect("Entry line Regex failed.")
41});
42/// A regular expression matching the project part of an entry line.
43pub static PROJECT_RE: Lazy<Regex> =
44    Lazy::new(|| Regex::new(r"\+(\S+)").expect("Entry project regex failed."));
45/// A regular expression matching the task part of an entry line.
46static TASKNAME_RE: Lazy<Regex> =
47    Lazy::new(|| Regex::new(r"@(\S+)").expect("Task name Regex failed."));
48/// A regular expression matching a stop line
49static STOP_LINE: Lazy<Regex> = Lazy::new(|| {
50    Regex::new(r"\A(\d{4}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12][0-9]|3[01]) (?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-6][0-9]) stop").expect("Stop line Regex failed")
51});
52/// Regular expression matching the year portion of an entry line.
53pub static YEAR_RE: Lazy<Regex> =
54    Lazy::new(|| Regex::new(r"^(\d\d\d\d)").expect("Date regex failed"));
55/// Regular expression extracting the marker from the line.
56pub static MARKER_RE: Lazy<Regex> =
57    Lazy::new(|| Regex::new(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d(.)").expect("Marker regex failed"));
58
59#[doc(inline)]
60use crate::date::{Date, DateTime};
61
62pub mod error;
63pub mod kind;
64
65/// Errors associated with the entry.
66pub use error::EntryError;
67/// The kind of entry
68pub use kind::EntryKind;
69
70/// Representation of an entry in the log
71///
72/// Objects of this type represent individual lines in the `timelog.txt` file.
73/// Each [`Entry`] has a date and time stamp, an optional project, and a task.
74#[derive(Debug, Clone, Eq, PartialEq)]
75#[must_use]
76pub struct Entry {
77    /// Time that this entry began
78    time:    DateTime,
79    /// An optional project name
80    project: Option<String>,
81    /// The text of the entry from the entry line
82    text:    String,
83    /// The type of the entry
84    kind:    EntryKind
85}
86
87/// # Line parsing tools
88impl Entry {
89    /// Parse the entry line text into the task name and detail parts if they exist.
90    pub fn task_breakdown(entry_text: &str) -> (Option<String>, Option<String>) {
91        if entry_text.is_empty() {
92            return (None, None);
93        }
94
95        let task = PROJECT_RE.replace(entry_text, "").trim().to_string();
96        if let Some(caps) = TASKNAME_RE.captures(&task) {
97            if let Some(tname) = caps.get(1) {
98                let detail = TASKNAME_RE.replace(&task, "").trim().to_string();
99                let tname = tname.as_str().to_string();
100                return (Some(tname), (!detail.is_empty()).then_some(detail));
101            }
102        }
103        (None, (!task.is_empty()).then_some(task))
104    }
105
106    /// Return `true` if the supplied string looks like a stop line.
107    pub fn is_stop_line(line: &str) -> bool { STOP_LINE.is_match(line) }
108
109    /// Extract a date/time string from a task line
110    pub fn datetime_from_line(line: &str) -> Option<&str> {
111        if line.is_empty() || Self::is_comment_line(line) {
112            return None;
113        }
114
115        if let Some(caps) = LAX_LINE_RE.captures(line) {
116            return caps.get(1).map(|s| s.as_str());
117        }
118        None
119    }
120
121    /// Extract a date string from a task line
122    pub fn date_from_line(line: &str) -> Option<&str> {
123        Self::datetime_from_line(line).and_then(|s| s.split_whitespace().next())
124    }
125
126    /// Return the year for the supplied entry line, if any.
127    pub fn extract_year(line: &str) -> Option<i32> {
128        if Self::is_comment_line(line) {
129            return None;
130        }
131
132        YEAR_RE
133            .captures(line)
134            .and_then(|cap| cap[0].parse::<i32>().ok())
135    }
136
137    /// Return `true` if the supplied line is a comment.
138    pub fn is_comment_line(line: &str) -> bool { line.starts_with('#') }
139}
140
141/// # Constructors
142impl Entry {
143    /// Create a new [`Entry`] representing the supplied task at the supplied time.
144    pub fn new(entry_text: &str, time: DateTime) -> Self {
145        Self::new_marked(entry_text, time, EntryKind::Start)
146    }
147
148    /// Create a new [`Entry`] representing the supplied task at the supplied time and optional
149    /// mark.
150    pub fn new_marked(entry_text: &str, time: DateTime, kind: EntryKind) -> Self {
151        let kind = if kind == EntryKind::Start && entry_text == STOP_CMD {
152            EntryKind::Stop
153        }
154        else {
155            kind
156        };
157        let oproject = PROJECT_RE.captures(entry_text)
158            .and_then(|caps| caps.get(1).map(|m| String::from(m.as_str())));
159        Self { time, project: oproject, text: entry_text.into(), kind }
160    }
161
162    /// Create a new [`Entry`] representing a stop entry for the supplied [`DateTime`]
163    pub fn new_stop(time: DateTime) -> Self { Self::new_marked(STOP_CMD, time, EntryKind::Stop) }
164
165    /// Create a new [`Entry`] representing the entry from the supplied line.
166    ///
167    /// This entry must be formatted as described in Format.md.
168    ///
169    /// # Errors
170    ///
171    /// Return an [`EntryError`] if the line is empty or formatted incorrectly.
172    pub fn from_line(line: &str) -> Result<Self, EntryError> {
173        if line.is_empty() {
174            return Err(EntryError::BlankLine);
175        }
176
177        match LAX_LINE_RE.captures(line) {
178            Some(caps) => {
179                let Some(stamp) = caps.get(1) else { return Err(EntryError::InvalidTimeStamp); };
180                let Ok(time) = DateTime::try_from(stamp.as_str()) else {
181                    return Err(EntryError::InvalidTimeStamp);
182                };
183                let kind = EntryKind::try_new(caps.get(2).and_then(|m| m.as_str().chars().next()))?;
184                Ok(Entry::new_marked(
185                    caps.get(3).map_or("", |m| m.as_str()),
186                    time,
187                    kind
188                ))
189            },
190            None => Err(if TIMESTAMP_RE.is_match(line) {
191                EntryError::MissingTask
192            }
193            else {
194                EntryError::InvalidTimeStamp
195            })
196        }
197    }
198}
199
200/// # Accessors
201impl Entry {
202    /// Return the [`&str`] designated as the project, if any, from the [`Entry`].
203    pub fn project(&self) -> Option<&str> { self.project.as_deref() }
204
205    /// Return the [`&str`] containing all of the [`Entry`] except the time and date.
206    pub fn entry_text(&self) -> &str { &self.text }
207
208    /// Return the [`String`] containing all of the [`Entry`] except the time and date.
209    pub fn task(&self) -> Option<String> { Self::task_breakdown(&self.text).0 }
210
211    /// Return the [`String`] containing all of the [`Entry`] except the time and date.
212    pub fn detail(&self) -> Option<String> { Self::task_breakdown(&self.text).1 }
213
214    /// Return the [`String`] containing all of the [`Entry`] except the time and date.
215    pub fn task_and_detail(&self) -> (Option<String>, Option<String>) {
216        Self::task_breakdown(&self.text)
217    }
218
219    /// Return the time for the start of the [`Entry`] in epoch seconds.
220    pub fn epoch(&self) -> i64 { self.time.timestamp() }
221
222    /// Return the date for the start of the [`Entry`] as a [`Date`]
223    pub fn date(&self) -> Date { self.time.date() }
224
225    /// Return the time for the start of the [`Entry`] as a [`DateTime`]
226    pub fn date_time(&self) -> DateTime { self.time }
227
228    /// Return the time for the start of the [`Entry`] as a [`DateTime`]
229    #[rustfmt::skip]
230    pub fn timestamp(&self) -> String {
231        format!("{} {:02}:{:02}", self.time.date(), self.time.hour(), self.time.minute())
232    }
233
234    /// Return the date stamp of the [`Entry`] in 'YYYY-MM-DD' format.
235    pub fn stamp(&self) -> String { self.date().to_string() }
236
237    /// Return `true` if this a start [`Entry`].
238    pub fn is_start(&self) -> bool { self.kind == EntryKind::Start }
239
240    /// Return `true` if this was a stop [`Entry`].
241    pub fn is_stop(&self) -> bool { self.kind == EntryKind::Stop }
242
243    /// Return `true` if this was an ignored [`Entry`].
244    pub fn is_ignore(&self) -> bool { self.kind == EntryKind::Ignored }
245
246    /// Return an ignored [`Entry`] converted from this one.
247    pub fn ignore(&self) -> Self { Self { kind: EntryKind::Ignored, ..self.clone() } }
248
249    /// Return `true` if this was a event [`Entry`].
250    pub fn is_event(&self) -> bool { self.kind == EntryKind::Event }
251}
252
253/// # Mutators
254impl Entry {
255    /// Return a new copy of the current [`Entry`] with the date and time
256    /// reset the the supplied value.
257    pub fn change_date_time(&self, date_time: DateTime) -> Self {
258        let mut entry = self.clone();
259        entry.time = date_time;
260        entry
261    }
262
263    /// Return a new copy of the current [`Entry`] with the date and time
264    /// reset the the supplied value.
265    pub fn change_text(&self, task: &str) -> Self {
266        if self.is_stop() { return self.clone(); }
267        Self::new_marked(task, self.time, self.kind)
268    }
269
270    /// Return a new [`Entry`] timestamped as the end of this date
271    pub fn to_day_end(&self) -> Self { Self { time: self.date().day_end(), ..self.clone() } }
272}
273
274impl Display for Entry {
275    /// Format the [`Entry`] formatted as described in Format.md.
276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277        let mark = match self.kind {
278            EntryKind::Ignored => '!',
279            EntryKind::Event => '^',
280            _ => ' '
281        };
282        write!(f, "{}{mark}{}", self.time, self.text)
283    }
284}
285
286impl PartialOrd for Entry {
287    /// This method returns an ordering between self and other values if one exists.
288    #[rustfmt::skip]
289    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
290        Some(self.time.cmp(&other.time)
291            .then_with(|| self.text.cmp(&other.text)))
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use spectral::prelude::*;
298
299    use super::*;
300
301    const CANONICAL_LINE: &str = "2013-06-05 10:00:02 +proj1 @do something";
302    const IGNORED_LINE: &str = "2013-06-05 10:00:02!+proj1 @do something";
303    const EVENT_LINE: &str = "2013-06-05 10:00:02^+proj1 @do something";
304    const STOP_LINE: &str = "2013-06-05 10:00:02 stop";
305
306    fn reference_time() -> i64 { DateTime::new((2013, 6, 5), (10, 0, 2)).unwrap().timestamp() }
307
308    #[test]
309    fn from_line_error_if_empty() {
310        assert_that!(Entry::from_line("")).is_err_containing(EntryError::BlankLine);
311    }
312
313    #[test]
314    #[rustfmt::skip]
315    fn is_comment_on_comment() {
316        assert_that!(Entry::is_comment_line("# Random comment")).is_true();
317        assert_that!(Entry::is_comment_line("#2013-06-05 10:00:02 +test @Commented")).is_true();
318    }
319
320    #[test]
321    #[rustfmt::skip]
322    fn is_comment_on_empty() {
323        assert_that!(Entry::is_comment_line("")).is_false();
324    }
325
326    #[test]
327    #[rustfmt::skip]
328    fn is_comment_on_entry() {
329        assert_that!(Entry::is_comment_line("2013-06-05 10:00:02 +test @Commented")).is_false();
330    }
331
332    #[test]
333    #[rustfmt::skip]
334    fn test_datetime_from_empty_line() {
335        assert_that!(Entry::datetime_from_line("")).is_none()
336    }
337
338    #[test]
339    #[rustfmt::skip]
340    fn test_datetime_from_commented_line() {
341        assert_that!(Entry::datetime_from_line("# Random comment")).is_none();
342        assert_that!(Entry::datetime_from_line("#2013-06-05 10:00:02 +test @Commented")).is_none()
343    }
344
345    #[test]
346    #[rustfmt::skip]
347    fn test_datetime_from_line() {
348        assert_that!(Entry::datetime_from_line(CANONICAL_LINE)).contains("2013-06-05 10:00:02");
349    }
350
351    #[test]
352    #[rustfmt::skip]
353    fn test_datetime_from_ignored_line() {
354        assert_that!(Entry::datetime_from_line(IGNORED_LINE)).contains("2013-06-05 10:00:02");
355    }
356
357    #[test]
358    #[rustfmt::skip]
359    fn test_date_from_empty_line() {
360        assert_that!(Entry::date_from_line("")).is_none()
361    }
362
363    #[test]
364    #[rustfmt::skip]
365    fn test_date_from_commented_line() {
366        assert_that!(Entry::date_from_line("# Random comment")).is_none();
367        assert_that!(Entry::date_from_line("#2013-06-05 10:00:02 +test @Commented")).is_none()
368    }
369
370    #[test]
371    fn test_date_from_line() {
372        assert_that!(Entry::date_from_line(CANONICAL_LINE)).contains("2013-06-05");
373    }
374
375    #[test]
376    fn test_date_from_ignored_line() {
377        assert_that!(Entry::date_from_line(IGNORED_LINE)).contains("2013-06-05");
378    }
379
380    #[test]
381    fn from_line_error_if_not_entry() {
382        assert_that!(Entry::from_line("This is not an entry"))
383            .is_err_containing(EntryError::InvalidTimeStamp);
384    }
385
386    #[test]
387    fn from_line_canonical_entry() {
388        let entry = Entry::from_line(CANONICAL_LINE).unwrap();
389        assert_that!(&entry.stamp()).is_equal_to(&String::from("2013-06-05"));
390        assert_that!(&entry.project()).contains_value("proj1");
391        assert_that!(&entry.entry_text()).is_equal_to("+proj1 @do something");
392        assert_that!(&entry.task()).contains_value(String::from("do"));
393        assert_that!(&entry.detail()).contains_value(String::from("something"));
394        assert_that!(&entry.task_and_detail())
395            .is_equal_to((Some(String::from("do")), Some(String::from("something"))));
396        assert_that!(&entry.epoch()).is_equal_to(&reference_time());
397        assert_that!(&entry.to_string().as_str()).is_equal_to(&CANONICAL_LINE);
398        assert_that!(&entry.is_stop()).is_false();
399        assert_that!(&entry.is_ignore()).is_false();
400        assert_that!(&entry.is_event()).is_false();
401    }
402
403    #[test]
404    fn new_canonical_entry() {
405        let canonical_time = DateTime::try_from("2013-06-05 10:00:02").unwrap();
406        let entry = Entry::new("+proj1 @do something", canonical_time);
407        assert_that!(&entry.stamp()).is_equal_to(String::from("2013-06-05"));
408        assert_that!(&entry.project()).contains_value("proj1");
409        assert_that!(&entry.entry_text()).is_equal_to("+proj1 @do something");
410        assert_that!(&entry.task()).contains_value(String::from("do"));
411        assert_that!(&entry.detail()).contains_value(String::from("something"));
412        assert_that!(&entry.task_and_detail())
413            .is_equal_to((Some(String::from("do")), Some(String::from("something"))));
414        assert_that!(&entry.epoch()).is_equal_to(&reference_time());
415        assert_that!(&entry.to_string().as_str()).is_equal_to(&CANONICAL_LINE);
416        assert_that!(&entry.is_stop()).is_false();
417        assert_that!(&entry.is_ignore()).is_false();
418        assert_that!(&entry.is_event()).is_false();
419    }
420
421    #[test]
422    fn from_line_no_task_entry() {
423        const LINE: &str = "2013-06-05 10:00:02 +proj1 do something";
424        let entry = Entry::from_line(LINE).unwrap();
425        assert_that!(&entry.stamp()).is_equal_to(&String::from("2013-06-05"));
426        assert_that!(&entry.project()).contains_value("proj1");
427        assert_that!(&entry.entry_text()).is_equal_to("+proj1 do something");
428        assert_that!(&entry.task()).is_none();
429        assert_that!(&entry.detail()).contains_value(String::from("do something"));
430        assert_that!(&entry.task_and_detail())
431            .is_equal_to((None, Some(String::from("do something"))));
432        assert_that!(&entry.epoch()).is_equal_to(&reference_time());
433        assert_that!(&entry.to_string().as_str()).is_equal_to(&LINE);
434        assert_that!(&entry.is_stop()).is_false();
435        assert_that!(&entry.is_ignore()).is_false();
436        assert_that!(&entry.is_event()).is_false();
437    }
438
439    #[test]
440    fn from_line_no_detail_entry() {
441        const LINE: &str = "2013-06-05 10:00:02 +proj1 @something";
442        let entry = Entry::from_line(LINE).unwrap();
443        assert_that!(&entry.stamp()).is_equal_to(&String::from("2013-06-05"));
444        assert_that!(&entry.project()).contains_value("proj1");
445        assert_that!(&entry.entry_text()).is_equal_to("+proj1 @something");
446        assert_that!(&entry.task()).contains_value(String::from("something"));
447        assert_that!(&entry.detail()).is_none();
448        assert_that!(&entry.task_and_detail()).is_equal_to((Some(String::from("something")), None));
449        assert_that!(&entry.epoch()).is_equal_to(&reference_time());
450        assert_that!(&entry.to_string().as_str()).is_equal_to(&LINE);
451        assert_that!(&entry.is_stop()).is_false();
452        assert_that!(&entry.is_ignore()).is_false();
453        assert_that!(&entry.is_event()).is_false();
454    }
455
456    #[test]
457    fn from_line_no_entry_text() {
458        const LINE: &str = "2013-06-05 10:00:02 +proj1";
459        let entry = Entry::from_line(LINE).unwrap();
460        assert_that!(&entry.stamp()).is_equal_to(&String::from("2013-06-05"));
461        assert_that!(&entry.project()).contains_value("proj1");
462        assert_that!(&entry.entry_text()).is_equal_to("+proj1");
463        assert_that!(&entry.task()).is_none();
464        assert_that!(&entry.detail()).is_none();
465        assert_that!(&entry.task_and_detail()).is_equal_to((None, None));
466        assert_that!(&entry.epoch()).is_equal_to(&reference_time());
467        assert_that!(&entry.to_string().as_str()).is_equal_to(&LINE);
468        assert_that!(&entry.is_stop()).is_false();
469        assert_that!(&entry.is_ignore()).is_false();
470        assert_that!(&entry.is_event()).is_false();
471    }
472
473    #[test]
474    fn from_line_stop_entry() {
475        let entry = Entry::from_line(STOP_LINE).unwrap();
476        assert_that!(&entry.stamp()).is_equal_to(String::from("2013-06-05"));
477        assert_that!(&entry.project()).is_none();
478        assert_that!(&entry.entry_text()).is_equal_to("stop");
479        assert_that!(&entry.task()).is_none();
480        assert_that!(&entry.detail()).contains_value(String::from("stop"));
481        assert_that!(&entry.task_and_detail()).is_equal_to((None, Some(String::from("stop"))));
482        assert_that!(&entry.epoch()).is_equal_to(&reference_time());
483        assert_that!(&entry.to_string().as_str()).is_equal_to(&STOP_LINE);
484        assert_that!(&entry.is_stop()).is_true();
485        assert_that!(&entry.is_ignore()).is_false();
486        assert_that!(&entry.is_event()).is_false();
487    }
488
489    #[test]
490    fn test_extract_year() {
491        let line = "2018-11-20 12:34:43 +test @Event";
492        assert_that!(Entry::extract_year(line)).contains(2018);
493    }
494
495    #[test]
496    fn test_extract_year_fail() {
497        let line = "xyzzy 2018-11-20 12:34:43 +test @Event";
498        assert_that!(Entry::extract_year(line)).is_none();
499    }
500
501    #[test]
502    fn new_stop_entry() {
503        let canonical_time = DateTime::try_from("2013-06-05 10:00:02").unwrap();
504        let entry = Entry::new("stop", canonical_time);
505        assert_that!(&entry.stamp()).is_equal_to(String::from("2013-06-05"));
506        assert_that!(&entry.project()).is_none();
507        assert_that!(&entry.entry_text()).is_equal_to("stop");
508        assert_that!(&entry.task()).is_none();
509        assert_that!(&entry.detail()).contains_value(String::from("stop"));
510        assert_that!(&entry.task_and_detail()).is_equal_to((None, Some(String::from("stop"))));
511        assert_that!(&entry.epoch()).is_equal_to(&reference_time());
512        assert_that!(&entry.to_string().as_str()).is_equal_to(&STOP_LINE);
513        assert_that!(&entry.is_stop()).is_true();
514        assert_that!(&entry.is_ignore()).is_false();
515        assert_that!(&entry.is_event()).is_false();
516    }
517
518    #[test]
519    fn from_line_ignored_entry() {
520        let entry = Entry::from_line(IGNORED_LINE).unwrap();
521        assert_that!(&entry.stamp()).is_equal_to(&String::from("2013-06-05"));
522        assert_that!(&entry.project()).contains_value("proj1");
523        assert_that!(&entry.entry_text()).is_equal_to("+proj1 @do something");
524        assert_that!(&entry.task()).contains_value(String::from("do"));
525        assert_that!(&entry.detail()).contains_value(String::from("something"));
526        assert_that!(&entry.task_and_detail())
527            .is_equal_to((Some(String::from("do")), Some(String::from("something"))));
528        assert_that!(&entry.epoch()).is_equal_to(&reference_time());
529        assert_that!(&entry.to_string().as_str()).is_equal_to(&IGNORED_LINE);
530        assert_that!(&entry.is_stop()).is_false();
531        assert_that!(&entry.is_ignore()).is_true();
532        assert_that!(&entry.is_event()).is_false();
533    }
534
535    #[test]
536    fn new_ignored_entry() {
537        let canonical_time = DateTime::try_from("2013-06-05 10:00:02").unwrap();
538        let entry = Entry::new_marked("+proj1 @do something", canonical_time, EntryKind::Ignored);
539        assert_that!(&entry.stamp()).is_equal_to(String::from("2013-06-05"));
540        assert_that!(&entry.project()).contains_value("proj1");
541        assert_that!(&entry.entry_text()).is_equal_to("+proj1 @do something");
542        assert_that!(&entry.task()).contains_value(String::from("do"));
543        assert_that!(&entry.detail()).contains_value(String::from("something"));
544        assert_that!(&entry.task_and_detail())
545            .is_equal_to((Some(String::from("do")), Some(String::from("something"))));
546        assert_that!(&entry.epoch()).is_equal_to(&reference_time());
547        assert_that!(&entry.to_string().as_str()).is_equal_to(&IGNORED_LINE);
548        assert_that!(&entry.is_stop()).is_false();
549        assert_that!(&entry.is_ignore()).is_true();
550        assert_that!(&entry.is_event()).is_false();
551    }
552
553    #[test]
554    fn from_line_event_entry() {
555        let entry = Entry::from_line(EVENT_LINE).unwrap();
556        assert_that!(&entry.stamp()).is_equal_to(&String::from("2013-06-05"));
557        assert_that!(&entry.project()).contains_value("proj1");
558        assert_that!(&entry.entry_text()).is_equal_to("+proj1 @do something");
559        assert_that!(&entry.task()).contains_value(String::from("do"));
560        assert_that!(&entry.detail()).contains_value(String::from("something"));
561        assert_that!(&entry.task_and_detail())
562            .is_equal_to((Some(String::from("do")), Some(String::from("something"))));
563        assert_that!(&entry.epoch()).is_equal_to(&reference_time());
564        assert_that!(&entry.to_string().as_str()).is_equal_to(&EVENT_LINE);
565        assert_that!(&entry.is_stop()).is_false();
566        assert_that!(&entry.is_ignore()).is_false();
567        assert_that!(&entry.is_event()).is_true();
568    }
569
570    #[test]
571    fn new_event_entry() {
572        let canonical_time = DateTime::try_from("2013-06-05 10:00:02").unwrap();
573        let entry = Entry::new_marked("+proj1 @do something", canonical_time, EntryKind::Event);
574        assert_that!(&entry.stamp()).is_equal_to(String::from("2013-06-05"));
575        assert_that!(&entry.project()).contains_value("proj1");
576        assert_that!(&entry.entry_text()).is_equal_to("+proj1 @do something");
577        assert_that!(&entry.task()).contains_value(String::from("do"));
578        assert_that!(&entry.detail()).contains_value(String::from("something"));
579        assert_that!(&entry.task_and_detail())
580            .is_equal_to((Some(String::from("do")), Some(String::from("something"))));
581        assert_that!(&entry.epoch()).is_equal_to(&reference_time());
582        assert_that!(&entry.to_string().as_str()).is_equal_to(&EVENT_LINE);
583        assert_that!(&entry.is_stop()).is_false();
584        assert_that!(&entry.is_ignore()).is_false();
585        assert_that!(&entry.is_event()).is_true();
586    }
587
588    #[test]
589    fn compare_entry() {
590        const LINE1: &str = "2013-06-05 10:00:02 +proj1";
591        const LINE2: &str = "2013-06-05 11:00:02 +proj1";
592        let entry1 = Entry::from_line(LINE1).unwrap();
593        let entry2 = Entry::from_line(LINE2).unwrap();
594        assert_that!(entry2 > entry1).is_true();
595        assert_that!(entry1 < entry2).is_true();
596        assert_that!(entry1 == entry1).is_true();
597    }
598
599    #[test]
600    fn test_change_date_time_start() {
601        let entry = Entry::from_line("2022-12-27 10:00:00 +proj1").unwrap();
602        let dt = DateTime::new((2022, 12, 27), (9, 50, 00)).unwrap();
603        let new_entry = entry.change_date_time(dt);
604
605        assert_that!(new_entry).is_not_equal_to(&entry);
606        let dt = DateTime::new((2022, 12, 27), (9, 50, 00)).unwrap();
607        assert_that!(new_entry.date_time()).is_equal_to(&dt);
608        assert_that!(new_entry.entry_text()).is_equal_to(&entry.entry_text());
609        assert_that!(new_entry.is_start()).is_true();
610    }
611
612    #[test]
613    fn test_change_date_time_event() {
614        let entry = Entry::from_line("2022-12-27 10:00:00^+event").unwrap();
615        let dt = DateTime::new((2022, 12, 27), (9, 50, 00)).unwrap();
616        let new_entry = entry.change_date_time(dt);
617
618        assert_that!(new_entry).is_not_equal_to(&entry);
619        let dt = DateTime::new((2022, 12, 27), (9, 50, 00)).unwrap();
620        assert_that!(new_entry.date_time()).is_equal_to(&dt);
621        assert_that!(new_entry.entry_text()).is_equal_to(&entry.entry_text());
622        assert_that!(new_entry.is_event()).is_true();
623    }
624
625    #[test]
626    fn test_change_date_time_ignored() {
627        let entry = Entry::from_line("2022-12-27 10:00:00!+proj1").unwrap();
628        let dt = DateTime::new((2022, 12, 27), (9, 50, 00)).unwrap();
629        let new_entry = entry.change_date_time(dt);
630
631        assert_that!(new_entry).is_not_equal_to(&entry);
632        let dt = DateTime::new((2022, 12, 27), (9, 50, 00)).unwrap();
633        assert_that!(new_entry.date_time()).is_equal_to(&dt);
634        assert_that!(new_entry.entry_text()).is_equal_to(&entry.entry_text());
635        assert_that!(new_entry.is_ignore()).is_true();
636    }
637
638    #[test]
639    fn test_change_date_time_stop() {
640        let entry = Entry::from_line("2022-12-27 10:00:00 stop").unwrap();
641        let dt = DateTime::new((2022, 12, 27), (9, 50, 00)).unwrap();
642        let new_entry = entry.change_date_time(dt);
643
644        assert_that!(new_entry).is_not_equal_to(&entry);
645        let dt = DateTime::new((2022, 12, 27), (9, 50, 00)).unwrap();
646        assert_that!(new_entry.date_time()).is_equal_to(&dt);
647        assert_that!(new_entry.entry_text()).is_equal_to(&entry.entry_text());
648        assert_that!(new_entry.is_stop()).is_true();
649    }
650
651    #[test]
652    fn test_change_text_start() {
653        let entry = Entry::from_line("2022-12-27 10:00:00 +proj1").unwrap();
654        let new_entry = entry.change_text("+proj2 @Changed");
655
656        assert_that!(new_entry).is_not_equal_to(&entry);
657        assert_that!(new_entry.date_time()).is_equal_to(&entry.date_time());
658        assert_that!(new_entry.entry_text()).is_equal_to("+proj2 @Changed");
659        assert_that!(new_entry.is_start()).is_true();
660    }
661
662    #[test]
663    fn test_change_text_event() {
664        let entry = Entry::from_line("2022-12-27 10:00:00^+event").unwrap();
665        let new_entry = entry.change_text("+proj2 @Changed");
666
667        assert_that!(new_entry).is_not_equal_to(&entry);
668        assert_that!(new_entry.date_time()).is_equal_to(&entry.date_time());
669        assert_that!(new_entry.entry_text()).is_equal_to("+proj2 @Changed");
670        assert_that!(new_entry.is_event()).is_true();
671    }
672
673    #[test]
674    fn test_change_text_ignored() {
675        let entry = Entry::from_line("2022-12-27 10:00:00!+proj1").unwrap();
676        let new_entry = entry.change_text("+proj2 @Changed");
677
678        assert_that!(new_entry).is_not_equal_to(&entry);
679        assert_that!(new_entry.date_time()).is_equal_to(&entry.date_time());
680        assert_that!(new_entry.entry_text()).is_equal_to("+proj2 @Changed");
681        assert_that!(new_entry.is_ignore()).is_true();
682    }
683
684    #[test]
685    fn test_change_text_stop() {
686        let entry = Entry::from_line("2022-12-27 10:00:00 stop").unwrap();
687        let new_entry = entry.change_text("+proj2 @Changed");
688
689        assert_that!(new_entry).is_equal_to(&entry);
690        assert_that!(new_entry.date_time()).is_equal_to(&entry.date_time());
691        assert_that!(new_entry.entry_text()).is_equal_to("stop");
692        assert_that!(new_entry.is_stop()).is_true();
693    }
694}