timestudy 0.10.4

Track your activities.
Documentation
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::path::PathBuf;

use chrono::prelude::*;
use thiserror::Error;

use crate::Activity;

#[derive(Error, Debug)]
pub enum ParseError {
    #[error(transparent)]
    FileError(#[from] io::Error),
    #[error(transparent)]
    CannotParseDateTime(#[from] chrono::format::ParseError),
    #[error("no line to parse")]
    NoLinetoParse,
}

/// Parse line into Activity.
/// lines have the format:
/// start - end # tag1 tag2 ... # description
/// where end, tags, and description may not exist, e.g. it could just be:
/// start # #
pub fn parse_line(line: String) -> Result<Activity, ParseError> {
    if line.is_empty() {
        return Err(ParseError::NoLinetoParse);
    }

    // split line into parts on # delimiter
    let parts = line
        .split('#')
        .map(|part| part.trim())
        .collect::<Vec<&str>>();

    let (start, end) = match parts[0].split_once(" - ") {
        // split successful; there's a start and an end datetime
        Some(v) => (
            v.0.trim().parse::<DateTime<Utc>>()?,
            Some(v.1.trim().parse::<DateTime<Utc>>()?),
        ),
        // only a start
        None => (parts[0].parse::<DateTime<Utc>>()?, None),
    };

    let tags = match parts.get(1) {
        Some(v) => {
            if v.trim() == "" {
                vec![]
            } else {
                v.split(' ').map(|tag| tag.to_string()).collect()
            }
        }
        None => vec![],
    };

    let description = match parts.get(2) {
        Some(v) => {
            if v.trim() == "" {
                None
            } else {
                Some(v.to_string())
            }
        }
        None => None,
    };

    Ok(Activity::new(start, end, tags, description))
}

/// Parse file into lines.
pub fn parse_file(path: &PathBuf) -> Result<Vec<Activity>, ParseError> {
    let f = match File::open(path) {
        Ok(v) => v,
        Err(e) => return Err(ParseError::FileError(e)),
    };

    let mut activities = vec![];
    for line in BufReader::new(f).lines() {
        let line = match line {
            Ok(v) => v,
            Err(e) => return Err(ParseError::FileError(e)),
        };
        activities.push(parse_line(line)?);
    }

    Ok(activities)
}

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

    #[test]
    fn parse_line_none_if_empty() {
        assert!(matches!(
            parse_line("".to_string()),
            Err(ParseError::NoLinetoParse)
        ))
    }

    #[test]
    fn parse_line_errs_if_bad_dt_string() {
        assert!(matches!(
            parse_line("not_a_dt # #".to_string()),
            Err(ParseError::CannotParseDateTime(_))
        ))
    }

    #[test]
    fn parse_line_creates_activity_with_only_start() {
        let line = format!("{:?} # #", Utc::now());
        let activity = parse_line(line).unwrap();
        assert!(activity.end.is_none());
        assert!(activity.tags.is_empty());
        assert!(activity.description.is_none());
    }

    #[test]
    fn parse_line_creates_activity_with_start_and_end() {
        let line = format!("{:?} - {:?} # #", Utc::now(), Utc::now());
        let activity = parse_line(line).unwrap();
        assert!(activity.end.is_some());
        assert!(activity.tags.is_empty());
        assert!(activity.description.is_none());
    }

    #[test]
    fn parse_line_parse_string_correctly_with_start() {
        let activity = parse_line("2022-06-25T18:14:00.324310806Z # #".to_string()).unwrap();
        assert_eq!(
            activity.start.to_string(),
            "2022-06-25 18:14:00.324310806 UTC".to_string()
        );
        assert!(activity.end.is_none());
        assert!(activity.tags.is_empty());
        assert!(activity.description.is_none());
    }

    #[test]
    fn parse_line_parse_string_correctly_with_start_and_end() {
        let activity = parse_line(
            "2022-06-25T18:14:00.324310806Z - 2022-06-25T19:14:00.324310806Z # #".to_string(),
        )
        .unwrap();
        assert_eq!(
            activity.start.to_string(),
            "2022-06-25 18:14:00.324310806 UTC".to_string()
        );
        assert_eq!(
            activity.end.unwrap().to_string(),
            "2022-06-25 19:14:00.324310806 UTC".to_string()
        );
        assert!(activity.tags.is_empty());
        assert!(activity.description.is_none());
    }

    #[test]
    fn parse_line_creates_activity_with_single_tag() {
        let line = format!("{:?} - {:?} # work #", Utc::now(), Utc::now());
        let activity = parse_line(line).unwrap();
        assert!(!activity.tags.is_empty());
    }

    #[test]
    fn parse_line_creates_activity_with_multiple_tags() {
        let line = format!("{:?} - {:?} # work pleasure #", Utc::now(), Utc::now());
        let activity = parse_line(line).unwrap();
        assert!(!activity.tags.is_empty());
    }

    #[test]
    fn parse_line_creates_activity_with_single_tag_in_vec() {
        let line = format!("{:?} - {:?} # work #", Utc::now(), Utc::now());
        let activity = parse_line(line).unwrap();
        assert_eq!(activity.tags, vec!["work".to_string()])
    }

    #[test]
    fn parse_line_creates_activity_with_multiple_tags_in_vec() {
        let line = format!("{:?} - {:?} # work pleasure #", Utc::now(), Utc::now());
        let activity = parse_line(line).unwrap();
        assert_eq!(
            activity.tags,
            vec!["work".to_string(), "pleasure".to_string()]
        )
    }
    #[test]
    fn parse_line_creates_activity_with_tags_surrounding_white_space_trimmed() {
        let line = format!("{:?} - {:?} #  work pleasure  # ", Utc::now(), Utc::now());
        let activity = parse_line(line).unwrap();
        assert_eq!(
            activity.tags,
            vec!["work".to_string(), "pleasure".to_string()]
        )
    }
    #[test]
    fn parse_line_creates_activity_with_description_and_no_tags() {
        let line = format!(
            "{:?} - {:?} # # This is a description.",
            Utc::now(),
            Utc::now()
        );
        let activity = parse_line(line).unwrap();
        assert!(activity.tags.is_empty());
        assert_eq!(
            activity.description.unwrap(),
            "This is a description.".to_owned()
        );
    }

    #[test]
    fn parse_file_errs_if_no_file() {
        assert!(matches!(
            parse_file(&PathBuf::from("notapath.txt")),
            Err(ParseError::FileError(_))
        ));
    }

    #[test]
    fn parsing_empty_file_returns_empty_vector() {
        test_utils::clean_up();
        let config = Config::setup();
        let lines = parse_file(&config.activities_path);
        assert!(lines.unwrap().is_empty());
    }

    #[test]
    fn parsing_file_returns_vector_with_correct_number_of_lines() {
        test_utils::clean_up();
        let config = Config::setup();
        test_utils::create_activities(3);
        let lines = parse_file(&config.activities_path);
        assert_eq!(lines.unwrap().len(), 3);
    }
}