ct-tracker-lib 0.1.1

A simple library for time tracking.
Documentation
use super::errors;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::ops::Add;
use std::path::PathBuf;

type DT = DateTime<Utc>;

#[derive(Debug, Serialize, Deserialize)]
pub struct Project {
    name: String,
    initial_date: DT,
    sessions: Vec<Session>,
}

impl Project {
    /// Get a reference to the project's name.
    pub fn name(&self) -> &String {
        &self.name
    }

    /// Get a reference to the project's initial date.
    pub fn initial_date(&self) -> &DT {
        &self.initial_date
    }

    /// Get a reference to the project's sessions.
    pub fn sessions(&self) -> &Vec<Session> {
        &self.sessions
    }

    // Only pub(super), because pub(in ...) can only contain ancestors and not siblings :(
    pub(super) fn create(name: &str) -> errors::CtResult<Project> {
        //// check if project already exists -> either fail or return existing project
        // This shouldn't be done, that's what ProjectFrame is for
        if super::has(name)? {
            Err(errors::CtError::Own("Project already exists"))
        } else {
            Ok(Project {
                name: name.to_owned(),
                // Maybe make time provider configurable so it can be mocked for easier testing
                initial_date: Utc::now(),
                sessions: vec![],
            })
        }
    }

    pub(super) fn from_json(json: &str) -> errors::CtResult<Project> {
        serde_json::from_str(json).map_err(|e| e.into())
    }

    pub fn json(&self, pretty: bool) -> errors::CtResult<String> {
        if pretty {
            serde_json::to_string_pretty(self).map_err(|e| e.into())
        } else {
            serde_json::to_string(self).map_err(|e| e.into())
        }
    }

    pub(super) fn start(&mut self) {
        // First check if a session is running
        // Should actually always be the last one
        for s in self.sessions.iter() {
            if s.end.is_none() {
                // We have an open session
                return;
            }
        }
        // We have no open session, so we can push one
        self.sessions.push(Session::new());
    }

    pub(super) fn stop(&mut self) {
        let open: Vec<&mut Session> = self.sessions.iter_mut().filter(|s| s.is_open()).collect();
        // For now assert that theres only one open session (or none)
        assert!(open.len() < 2);

        // We have asserted that there's less than one session,
        // so we can just use a loop to close them all
        for s in open {
            s.close();
        }
    }

    pub fn is_open(&self) -> bool {
        self.sessions.iter().fold(false, |acc, x| acc | x.is_open())
    }

    pub fn duration(&self) -> Duration {
        self.sessions
            .iter()
            .map(|s| s.timespan())
            .fold(Duration::zero(), Duration::add)
    }

    pub fn session_count(&self) -> usize {
        self.sessions.len()
    }

    pub fn load(path: &PathBuf) -> errors::CtResult<Project> {
        assert!(path.exists());
        assert!(path.is_file());
        // Project already exists: Try to load it and return a new frame
        let json = std::fs::read_to_string(&path)?;
        Project::from_json(json.as_str())
    }

    pub fn load_from_name(name: &str) -> errors::CtResult<Project> {
        Self::load(&super::project_path(name)?)
    }
}

#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
pub struct Session {
    start: DT,
    end: Option<DT>,
}

impl Session {
    fn new() -> Session {
        Session {
            start: Utc::now(),
            end: None,
        }
    }

    pub fn is_open(&self) -> bool {
        self.end.is_none()
    }

    fn close(&mut self) {
        assert!(self.end == None);
        self.end = Some(Utc::now());
    }

    pub fn timespan(&self) -> Duration {
        let start = self.start;
        // If this session is running, its current spanning time runs up until Utc::now()
        let end = self.end.unwrap_or_else(Utc::now);
        end.signed_duration_since(start)
    }

    pub fn start_time(&self) -> &DT {
        &self.start
    }

    pub fn end_time(&self) -> Option<&DT> {
        if let Some(t) = &self.end {
            Some(t)
        } else {
            None
        }
    }
}

#[cfg(test)]
mod tests {
    use chrono::{Duration, Utc};
    use std::ops::Add;

    use super::{Project, Session, DT};

    struct TimeProvider {
        /// The current internal state
        state: i32,
        /// The number of timestamps to give
        num: i32,
        /// The base timestamp
        base_time: DT,
        timestep: Duration,
    }

    impl TimeProvider {
        fn new(timestep: Duration) -> TimeProvider {
            Self::lim(-1, timestep)
        }

        fn lim(num: i32, timestep: Duration) -> TimeProvider {
            TimeProvider {
                state: 0,
                num,
                base_time: Utc::now(),
                timestep,
            }
        }
    }

    impl Iterator for TimeProvider {
        type Item = DT;

        fn next(&mut self) -> Option<Self::Item> {
            if self.num <= 0 || self.state <= self.num {
                self.state += 1;
                self.base_time = self.base_time.add(self.timestep);
                Some(self.base_time)
            } else {
                None
            }
        }
    }

    impl Project {
        fn create_test(session_count: usize, timestep: Duration) -> Project {
            let mut t = TimeProvider::new(timestep);
            let initial_date = t.next().unwrap();
            let mut sessions = Vec::with_capacity(session_count);
            for _ in 1..=session_count {
                sessions.push(Session::mock(&mut t));
            }
            Project {
                name: "TEST".to_owned(),
                initial_date,
                sessions,
            }
        }
    }

    impl Session {
        fn mock(t: &mut TimeProvider) -> Session {
            if let Some(time) = t.next() {
                Session {
                    start: time,
                    end: t.next(),
                }
            } else {
                Session {
                    start: Utc::now(),
                    end: Some(Utc::now()),
                }
            }
        }
    }

    #[test]
    fn duration() {
        let timestep = Duration::seconds(5);
        let session_count = 10;
        let p = Project::create_test(session_count, timestep);
        assert_eq!(
            Duration::seconds(timestep.num_seconds() * session_count as i64),
            p.duration()
        );
    }

    #[test]
    fn multiple_start() {
        let mut p = Project::create("TEST").unwrap();

        // Multiple starts shouldn't lead to multiple sessions
        p.start();
        p.start();
        p.start();

        println!("Project has {} sessions", p.sessions.len());
        assert_eq!(p.sessions.len(), 1);

        // ...therefore one stop should suffice
        p.stop();

        let has_open_sessions = p.sessions.iter().fold(false, |acc, x| acc | x.is_open());
        println!("Project has open sessions? {}", has_open_sessions);
        assert!(!has_open_sessions);

        println!("Test finished successfully");
    }

    #[test]
    fn session_timespan() {
        let dur = Duration::seconds(5);
        let mut t = TimeProvider::new(dur);
        let s = Session::mock(&mut t);
        assert_eq!(dur, s.timespan());
    }
}