1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
use chrono::prelude::*;
use serde::Deserialize;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::fmt;
use std::io::{self, BufRead};

/// An enum to represent errors occurring while processing report data from Timewarrior
#[derive(Debug)]
pub enum ReportError {
    /// An error, which occurred while parsing data from standard in
    IO(String),
    /// An error, which occurred while deserializing or serializing a session from JSON
    SerdeJson(String),
    /// Some other error
    Other(String),
}

impl std::error::Error for ReportError {}

impl fmt::Display for ReportError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ReportError::IO(e) => write!(f, "IOError: {}", e),
            ReportError::SerdeJson(e) => write!(f, "SerdeJsonError: {}", e),
            ReportError::Other(e) => write!(f, "Other Error: {}", e),
        }
    }
}

impl From<io::Error> for ReportError {
    fn from(error: io::Error) -> Self {
        ReportError::IO(error.to_string())
    }
}

impl From<serde_json::Error> for ReportError {
    fn from(error: serde_json::Error) -> Self {
        ReportError::SerdeJson(error.to_string())
    }
}

mod my_date_format {
    use chrono::{DateTime, Local, TimeZone, Utc};
    use serde::{self, Deserialize, Deserializer};

    const FORMAT: &str = "%Y%m%dT%H%M%SZ";

    pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Local>, D::Error>
    where
        D: Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        Ok(Utc
            .datetime_from_str(&s, FORMAT)
            .map_err(serde::de::Error::custom)?
            .with_timezone(&Local))
    }
}

mod my_optional_date_format {
    use chrono::{DateTime, Local, TimeZone, Utc};
    use serde::{self, Deserialize, Deserializer};

    const FORMAT: &str = "%Y%m%dT%H%M%SZ";

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Local>>, D::Error>
    where
        D: Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        Ok(Some(
            Utc.datetime_from_str(&s, FORMAT)
                .map_err(serde::de::Error::custom)?
                .with_timezone(&Local),
        ))
    }
}

/// A representation of the data within the report
#[derive(Debug, Eq)]
pub struct TimewarriorData {
    /// The configurations passed to the report
    pub config: HashMap<String, String>,
    /// A vector of all tracked sessions within the report
    pub sessions: Vec<Session>,
}

impl PartialEq for TimewarriorData {
    fn eq(&self, other: &Self) -> bool {
        self.config == other.config && self.sessions == other.sessions
    }
}

impl TimewarriorData {
    /// Read the report from standard input
    ///
    /// This should be the usual way to read the report data.
    pub fn from_stdin() -> Result<Self, ReportError> {
        let mut input_string = String::new();
        for line in io::stdin().lock().lines() {
            input_string = format!("{}\n{}", input_string, line?);
        }
        Self::from_string(input_string.trim().into())
    }

    /// Read the report from a given string
    ///
    /// # Example
    ///
    /// ```rust
    /// use timewarrior_report::TimewarriorData;
    ///
    /// let report_data = TimewarriorData::from_string("test: test\n\n[]".into()).unwrap();
    /// assert_eq!(
    ///     report_data,
    ///     TimewarriorData {
    ///         config: [("test".to_string(), "test".to_string())]
    ///             .iter()
    ///             .cloned()
    ///             .collect(),
    ///         sessions: Vec::new(),
    ///     }
    /// );
    /// ```
    pub fn from_string(input: String) -> Result<Self, ReportError> {
        let input_vec = &input.split("\n\n").collect::<Vec<&str>>();
        let mut config = HashMap::new();
        for line in input_vec[0].lines() {
            let setting = line.split(": ").collect::<Vec<&str>>();
            config.insert(setting[0].into(), setting[1].into());
        }
        Ok(TimewarriorData {
            config,
            sessions: Session::from_json(&input_vec[1])?,
        })
    }
}
/// A tracked session from Timewarrior
#[derive(Debug, Deserialize, Eq)]
pub struct Session {
    /// ID of the session within Timewarrior
    pub id: usize,
    /// Start time of the session
    #[serde(with = "my_date_format")]
    pub start: DateTime<Local>,
    /// End time of the session. `Some(DateTime<Local>)` if it did end, `None` otherwise.
    #[serde(default)]
    #[serde(with = "my_optional_date_format")]
    pub end: Option<DateTime<Local>>,
    /// Tags attached to the session
    pub tags: Vec<String>,
    /// Annotation of the session. `Some(String)` if the session has an annotation, `None`
    /// otherwise.
    pub annotation: Option<String>,
}

impl PartialEq for Session {
    fn eq(&self, other: &Self) -> bool {
        self.start == other.start
            && self.end == other.end
            && self.id == other.id
            && self.tags == other.tags
            && self.annotation == other.annotation
    }
}

impl Ord for Session {
    fn cmp(&self, other: &Self) -> Ordering {
        self.id.cmp(&other.id)
    }
}

impl PartialOrd for Session {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(&other))
    }
}

impl Session {
    fn from_json(data: &str) -> Result<Vec<Session>, ReportError> {
        Ok(serde_json::from_str::<Vec<Session>>(data)?)
    }
}

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

    #[test]
    fn create_simple_timewarrior_data() {
        let report_data = TimewarriorData::from_string("test: test\n\n[]".into()).unwrap();
        assert_eq!(
            report_data,
            TimewarriorData {
                config: [("test".to_string(), "test".to_string())]
                    .iter()
                    .cloned()
                    .collect(),
                sessions: Vec::new(),
            }
        );
    }

    #[test]
    fn create_session_without_minial_data() {
        let test_session = serde_json::from_str::<Session>(
            "{\"id\":1,\"start\":\"20210711T103400Z\",\"tags\":[]}",
        )
        .unwrap();
        assert_eq!(
            test_session,
            Session {
                id: 1,
                start: DateTime::<Utc>::from_utc(
                    NaiveDate::from_ymd(2021, 07, 11).and_hms(10, 34, 00),
                    Utc
                )
                .with_timezone(&Local),
                end: None,
                tags: vec![],
                annotation: None,
            }
        );
    }

    #[test]
    fn create_session_without_end_date() {
        let test_session = serde_json::from_str::<Session>(
            "{\"id\":1,\"start\":\"20210711T103400Z\",\"tags\":[\"test\"],\"annotation\":\"this is a test\"}",
        )
        .unwrap();
        assert_eq!(
            test_session,
            Session {
                id: 1,
                start: DateTime::<Utc>::from_utc(
                    NaiveDate::from_ymd(2021, 07, 11).and_hms(10, 34, 00),
                    Utc
                )
                .with_timezone(&Local),
                end: None,
                tags: vec!["test".to_string()],
                annotation: Some("this is a test".to_string()),
            }
        );
    }

    #[test]
    fn create_session_with_end_date() {
        let test_session = serde_json::from_str::<Session>(
            "{\"id\":1,\"start\":\"20210711T103400Z\",\"end\":\"20210711T113400Z\",\"tags\":[\"test\"],\"annotation\":\"this is a test\"}",
        )
        .unwrap();
        assert_eq!(
            test_session,
            Session {
                id: 1,
                start: DateTime::<Utc>::from_utc(
                    NaiveDate::from_ymd(2021, 07, 11).and_hms(10, 34, 00),
                    Utc
                )
                .with_timezone(&Local),
                end: Some(
                    DateTime::<Utc>::from_utc(
                        NaiveDate::from_ymd(2021, 07, 11).and_hms(11, 34, 00),
                        Utc
                    )
                    .with_timezone(&Local)
                ),
                tags: vec!["test".to_string()],
                annotation: Some("this is a test".to_string()),
            }
        );
    }
}