tgl_cli/
svc.rs

1//! High-level client for interacting with Toggl. Uses the [api].
2
3use crate::api;
4use chrono::{DateTime, Duration, TimeZone, Utc};
5
6const CREATED_WITH: &str = "github.com/blachniet/tgl";
7
8pub struct Client {
9    c: api::Client,
10    get_now: fn() -> DateTime<Utc>,
11    project_cache: elsa::map::FrozenMap<(i64, i64), Box<Project>>,
12}
13
14impl Client {
15    pub fn new(token: String, get_now: fn() -> DateTime<Utc>) -> Result<Self> {
16        Ok(Self {
17            c: api::Client::new(token)?,
18            get_now,
19            project_cache: elsa::map::FrozenMap::new(),
20        })
21    }
22
23    pub fn get_latest_entries(&self) -> Result<Vec<TimeEntry>> {
24        let api_entries = self.c.get_time_entries(None)?;
25        let entries: Result<Vec<_>> = api_entries
26            .into_iter()
27            .map(|e| self.build_time_entry(e))
28            .collect();
29
30        entries
31    }
32
33    fn build_time_entry(&self, api_entry: api::TimeEntry) -> Result<TimeEntry> {
34        let project_id = api_entry.project_id.map(|pid| pid.as_i64().unwrap());
35        let project = match project_id {
36            Some(pid) => self.get_project(api_entry.workspace_id.as_i64().unwrap(), pid)?,
37            None => None,
38        };
39        let (duration, is_running) = parse_duration((self.get_now)(), api_entry.duration);
40        let start: Option<DateTime<Utc>> = match api_entry.start {
41            Some(s) => Some(s.parse()?),
42            None => None,
43        };
44        let stop: Option<DateTime<Utc>> = match api_entry.stop {
45            Some(s) => Some(s.parse()?),
46            None => None,
47        };
48
49        Ok(TimeEntry {
50            description: api_entry.description,
51            duration,
52            is_running,
53            project_id,
54            project_name: project.map(|p| p.name.to_string()),
55            start,
56            stop,
57            workspace_id: api_entry.workspace_id.as_i64().unwrap(),
58        })
59    }
60
61    pub fn start_time_entry(
62        &self,
63        workspace_id: i64,
64        project_id: Option<i64>,
65        description: Option<&str>,
66    ) -> Result<TimeEntry> {
67        let now = (self.get_now)();
68        let api_entry = self.c.create_time_entry(api::NewTimeEntry {
69            created_with: CREATED_WITH.to_string(),
70            description: description.map(|d| d.to_string()),
71            duration: (-now.timestamp()).into(),
72            project_id: project_id.map(|i| i.into()),
73            start: now.to_rfc3339(),
74            stop: None,
75            task_id: None,
76            workspace_id: workspace_id.into(),
77        })?;
78        let entry = self.build_time_entry(api_entry)?;
79
80        Ok(entry)
81    }
82
83    pub fn stop_current_time_entry(&self) -> Result<Option<TimeEntry>> {
84        if let Some(api_entry) = self.c.get_current_entry()? {
85            let api_entry = self
86                .c
87                .stop_time_entry(&api_entry.workspace_id, &api_entry.id)?;
88            let entry = self.build_time_entry(api_entry)?;
89
90            Ok(Some(entry))
91        } else {
92            Ok(None)
93        }
94    }
95
96    fn get_project(&self, workspace_id: i64, project_id: i64) -> Result<Option<&Project>> {
97        let key = (workspace_id, project_id);
98        if let Some(project) = self.project_cache.get(&key) {
99            return Ok(Some(project));
100        }
101
102        let workspace_id_num = workspace_id.into();
103        let projects = self.c.get_projects(&workspace_id_num)?;
104        for p in projects {
105            self.project_cache.insert(
106                (workspace_id, p.id.as_i64().expect("parse number as i64")),
107                Box::new(Project {
108                    active: p.active,
109                    id: p.id.as_i64().unwrap(),
110                    name: p.name,
111                }),
112            );
113        }
114
115        Ok(self.project_cache.get(&key))
116    }
117
118    pub fn get_projects(&self, workspace_id: i64) -> Result<Vec<Project>> {
119        let api_projects = self.c.get_projects(&workspace_id.into())?;
120        let mut projects = Vec::new();
121
122        for p in api_projects {
123            self.project_cache.insert(
124                (workspace_id, p.id.as_i64().expect("parse number as i64")),
125                Box::new(Project {
126                    active: p.active,
127                    id: p.id.as_i64().unwrap(),
128                    name: p.name.to_string(),
129                }),
130            );
131
132            projects.push(Project {
133                active: p.active,
134                id: p.id.as_i64().unwrap(),
135                name: p.name,
136            });
137        }
138
139        Ok(projects)
140    }
141
142    pub fn get_workspaces(&self) -> Result<Vec<Workspace>> {
143        let workspaces = self.c.get_workspaces()?;
144        Ok(workspaces
145            .into_iter()
146            .map(|w| Workspace {
147                id: w.id.as_i64().unwrap(),
148                name: w.name,
149            })
150            .collect())
151    }
152}
153
154/// Creates a [`chrono::Duration`] from a Toggle API duration.
155///
156/// Returns a tuple containing the duration value and bool. If the bool
157/// is `true`, then the associated timer was running. If the bool is
158/// `false`, then the associated timer was not running.
159///
160/// Panics if `duration` cannot be represented as an `i64` or is out-of-range.
161fn parse_duration(now: DateTime<Utc>, duration: serde_json::Number) -> (Duration, bool) {
162    let duration = duration.as_i64().unwrap();
163    if duration < 0 {
164        (
165            // Running entry is represented as the negative epoch timestamp
166            // of the start time.
167            now - Utc.timestamp_opt(-duration, 0).unwrap(),
168            true,
169        )
170    } else {
171        (Duration::seconds(duration), false)
172    }
173}
174
175#[derive(thiserror::Error, Debug)]
176pub enum Error {
177    #[error("reqwest error")]
178    Reqwest(#[from] reqwest::Error),
179    #[error("chrono parse error")]
180    ChronoParse(#[from] chrono::ParseError),
181}
182
183type Result<T> = std::result::Result<T, Error>;
184
185#[derive(Debug)]
186pub struct TimeEntry {
187    pub description: Option<String>,
188    pub duration: Duration,
189    pub is_running: bool,
190    pub project_id: Option<i64>,
191    pub project_name: Option<String>,
192    pub start: Option<DateTime<Utc>>,
193    pub stop: Option<DateTime<Utc>>,
194    pub workspace_id: i64,
195}
196
197#[derive(Debug)]
198pub struct Project {
199    pub active: bool,
200    pub id: i64,
201    pub name: String,
202}
203
204#[derive(Debug)]
205pub struct Workspace {
206    pub id: i64,
207    pub name: String,
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn parse_duration_stopped() {
216        let now = Utc.timestamp_opt(1404810600, 0).unwrap();
217        let (dur, is_running) = parse_duration(now, 30.into());
218
219        assert!(!is_running);
220        assert_eq!(30, dur.num_seconds());
221        assert_eq!(0, dur.subsec_nanos());
222    }
223
224    #[test]
225    fn parse_duration_running() {
226        let now = Utc.timestamp_opt(1404810630, 0).unwrap();
227        let (dur, is_running) = parse_duration(now, (-1404810600).into());
228
229        assert!(is_running);
230        assert_eq!(30, dur.num_seconds());
231        assert_eq!(0, dur.subsec_nanos());
232    }
233}