trackie/
lib.rs

1use std::error::Error;
2
3use chrono::Local;
4
5use crate::cli::{
6    Opts, Subcommand, TimingCommand, DEFAULT_EMPTY_STATUS_MSG, DEFAULT_STATUS_FORMAT,
7};
8use crate::persistence::{load_or_create_log, save_log, FileHandler};
9use crate::pretty_string::PrettyString;
10use crate::report_creator::ReportCreator;
11use crate::time_log::TimeLog;
12use colored::Colorize;
13use std::fmt::Display;
14use std::fmt::Formatter;
15
16pub mod cli;
17pub mod persistence;
18mod pretty_string;
19mod report_creator;
20mod time_log;
21
22pub fn run_app(o: Opts, fh: &mut dyn FileHandler) -> Result<(), TrackieError> {
23    let mut modified = false;
24    let mut log = load_or_create_log(fh)?;
25    let report_creator = ReportCreator::new(&log);
26
27    match o.sub_cmd {
28        Subcommand::Start(p) => {
29            modified = true;
30            start_tracking(&mut log, p)?;
31        }
32        Subcommand::Stop(_) => {
33            modified = true;
34            let pending = log.stop_pending()?;
35            let dur = pending.get_pending_duration();
36            println!(
37                "Tracked {} on project {}",
38                dur.to_pretty_string().bold(),
39                pending.project_name.italic()
40            );
41        }
42        Subcommand::Report(o) => {
43            let report = report_creator.report_days(Local::today(), o.days, o.include_empty_days);
44            match o.json {
45                true => println!("{}", serde_json::to_string_pretty(&report)?),
46                false => println!("{}", report),
47            };
48        }
49        Subcommand::Status(s) => match &log.pending {
50            None => {
51                let msg = s
52                    .fallback
53                    .unwrap_or_else(|| DEFAULT_EMPTY_STATUS_MSG.to_string());
54
55                return Err(TrackieError {
56                    msg,
57                    print_as_error: false,
58                });
59            }
60            Some(p) => {
61                let format = s
62                    .format
63                    .unwrap_or_else(|| DEFAULT_STATUS_FORMAT.to_string());
64
65                let output = format
66                    .replace("%p", p.project_name.as_str())
67                    .replace("%d", p.start.format("%F").to_string().as_str())
68                    .replace("%t", p.start.format("%R").to_string().as_str())
69                    .replace("%D", p.get_pending_duration().to_pretty_string().as_str());
70
71                println!("{}", output);
72            }
73        },
74        Subcommand::Resume(_) => match (&log.pending, log.get_latest_entry()) {
75            (None, Some(s)) => {
76                modified = true;
77                let name = s.project_name.clone();
78                start_tracking(&mut log, TimingCommand { project_name: name })?;
79            }
80            (Some(p), _) => {
81                return Err(TrackieError::new(
82                    format!("Already tracking time for project {}", p.project_name).as_str(),
83                ))
84            }
85            (_, None) => {
86                return Err(TrackieError::new(
87                    "Unable to find latest time log. Maybe no time was ever tracked?",
88                ));
89            }
90        },
91    }
92
93    if modified {
94        save_log(fh, &log)?;
95    }
96
97    Ok(())
98}
99
100fn start_tracking(log: &mut TimeLog, p: TimingCommand) -> Result<(), Box<dyn Error>> {
101    if let Some(warn) = log.start_log(&p.project_name)? {
102        println!("{} {}", "WARN:".yellow(), warn);
103    }
104    println!(
105        "Tracking time for project {}",
106        p.project_name.as_str().italic()
107    );
108    Ok(())
109}
110
111#[derive(Debug)]
112pub struct TrackieError {
113    msg: String,
114    pub print_as_error: bool,
115}
116
117impl TrackieError {
118    fn new(msg: &str) -> TrackieError {
119        TrackieError {
120            msg: msg.to_string(),
121            print_as_error: true,
122        }
123    }
124}
125
126impl Display for TrackieError {
127    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
128        write!(f, "{}", self.msg)
129    }
130}
131
132impl From<Box<dyn Error>> for TrackieError {
133    fn from(i: Box<dyn Error>) -> Self {
134        TrackieError::new(i.to_string().as_str())
135    }
136}
137
138impl From<serde_json::Error> for TrackieError {
139    fn from(e: serde_json::Error) -> Self {
140        TrackieError::new(e.to_string().as_str())
141    }
142}
143
144impl Error for TrackieError {}
145
146#[cfg(test)]
147mod tests {
148    use crate::cli::{
149        EmptyCommand, Opts, StatusCommand, Subcommand, TimingCommand, DEFAULT_EMPTY_STATUS_MSG,
150    };
151    use crate::persistence::FileHandler;
152    use crate::run_app;
153    use std::error::Error;
154
155    #[test]
156    fn status_on_empty_fallback() {
157        let mut handler = TestFileHandler::default();
158        let e = run_app(
159            Opts {
160                sub_cmd: Subcommand::Status(StatusCommand {
161                    format: None,
162                    fallback: Some("Foo".to_string()),
163                }),
164            },
165            &mut handler,
166        );
167        assert!(e.is_err());
168        assert_eq!(e.unwrap_err().msg, "Foo");
169    }
170
171    #[test]
172    fn status_on_empty_no_fallback() {
173        let mut handler = TestFileHandler::default();
174        let e = run_app(
175            Opts {
176                sub_cmd: Subcommand::Status(StatusCommand {
177                    format: None,
178                    fallback: None,
179                }),
180            },
181            &mut handler,
182        );
183        assert!(e.is_err());
184        assert_eq!(e.unwrap_err().msg, DEFAULT_EMPTY_STATUS_MSG);
185    }
186
187    #[test]
188    fn start_tracking() -> Result<(), Box<dyn Error>> {
189        let mut handler = TestFileHandler::default();
190        run_app(
191            Opts {
192                sub_cmd: Subcommand::Start(TimingCommand {
193                    project_name: "Foo".to_string(),
194                }),
195            },
196            &mut handler,
197        )?;
198
199        let content = handler.content.unwrap();
200        assert!(content.contains("Foo"));
201        Ok(())
202    }
203
204    #[test]
205    fn resume_after_stop() -> Result<(), Box<dyn Error>> {
206        let mut handler = TestFileHandler::default();
207
208        let x = run_app(
209            Opts {
210                sub_cmd: Subcommand::Resume(EmptyCommand {}),
211            },
212            &mut handler,
213        );
214        assert!(x.is_err());
215
216        run_app(
217            Opts {
218                sub_cmd: Subcommand::Start(TimingCommand {
219                    project_name: "Foo".to_string(),
220                }),
221            },
222            &mut handler,
223        )?;
224        run_app(
225            Opts {
226                sub_cmd: Subcommand::Stop(EmptyCommand {}),
227            },
228            &mut handler,
229        )?;
230
231        let status = run_app(
232            Opts {
233                sub_cmd: Subcommand::Status(StatusCommand {
234                    fallback: None,
235                    format: None,
236                }),
237            },
238            &mut handler,
239        );
240        assert!(status.is_err());
241
242        run_app(
243            Opts {
244                sub_cmd: Subcommand::Resume(EmptyCommand {}),
245            },
246            &mut handler,
247        )?;
248
249        let status = run_app(
250            Opts {
251                sub_cmd: Subcommand::Status(StatusCommand {
252                    fallback: None,
253                    format: None,
254                }),
255            },
256            &mut handler,
257        );
258        assert!(status.is_ok());
259
260        Ok(())
261    }
262
263    #[test]
264    fn status_after_start_tracking() -> Result<(), Box<dyn Error>> {
265        let mut handler = TestFileHandler::default();
266        run_app(
267            Opts {
268                sub_cmd: Subcommand::Start(TimingCommand {
269                    project_name: "Foo".to_string(),
270                }),
271            },
272            &mut handler,
273        )?;
274
275        run_app(
276            Opts {
277                sub_cmd: Subcommand::Status(StatusCommand {
278                    format: None,
279                    fallback: None,
280                }),
281            },
282            &mut handler,
283        )?;
284        Ok(())
285    }
286
287    #[test]
288    fn stop_tracking() -> Result<(), Box<dyn Error>> {
289        let mut handler = TestFileHandler::default();
290        run_app(
291            Opts {
292                sub_cmd: Subcommand::Start(TimingCommand {
293                    project_name: "Foo".to_string(),
294                }),
295            },
296            &mut handler,
297        )?;
298
299        run_app(
300            Opts {
301                sub_cmd: Subcommand::Stop(EmptyCommand {}),
302            },
303            &mut handler,
304        )?;
305
306        let status = run_app(
307            Opts {
308                sub_cmd: Subcommand::Status(StatusCommand {
309                    format: None,
310                    fallback: None,
311                }),
312            },
313            &mut handler,
314        );
315
316        assert!(status.is_err());
317
318        let second_stop = run_app(
319            Opts {
320                sub_cmd: Subcommand::Stop(EmptyCommand {}),
321            },
322            &mut handler,
323        );
324
325        assert!(second_stop.is_err());
326        Ok(())
327    }
328
329    struct TestFileHandler {
330        content: Option<String>,
331    }
332
333    impl Default for TestFileHandler {
334        fn default() -> Self {
335            Self { content: None }
336        }
337    }
338
339    impl FileHandler for TestFileHandler {
340        fn read_file(&self) -> Result<Option<String>, Box<dyn Error>> {
341            Ok(self.content.clone())
342        }
343
344        fn write_file(&mut self, content: &str) -> Result<(), Box<dyn Error>> {
345            self.content = Some(content.to_string());
346            Ok(())
347        }
348    }
349}