1use crate::{
2 metadata::MetadataKeyValuePair,
3 parser::task_lexicon::{parse_task, Expression},
4 settings::Settings,
5};
6use chrono::{DateTime, Duration, Local, NaiveDateTime};
7use color_eyre::eyre::{bail, Context, Result};
8use file_lock::{FileLock, FileOptions};
9use glob::glob;
10use serde::{Deserialize, Serialize};
11use simple_file_rotation::FileRotation;
12use std::{
13 collections::BTreeMap,
14 fs::File,
15 io::{Read, Write},
16 path::PathBuf,
17 str::FromStr,
18};
19use strum::{EnumString, IntoStaticStr};
20use thiserror::Error;
21use uuid::Uuid;
22
23#[cfg(feature = "notify")]
24use crate::notify::DatabaseFileType;
25
26#[derive(EnumString, IntoStaticStr, clap::ValueEnum, Clone, Eq, PartialEq, Debug)]
29pub enum TaskPriority {
30 Low,
32 Medium,
34 High,
36 Critical,
38}
39
40#[derive(Error, Debug, PartialEq, Eq)]
42pub enum TaskError {
43 #[error("only one project identifier allowed")]
45 MultipleProjectsNotAllowed,
46 #[error("only one priority identifier allowed")]
48 MultiplePrioritiesNotAllowed,
49 #[error("only one due date identifier allowed")]
51 MultipleDuedatesNotAllowed,
52 #[error("only one instance of metadata key `{0}` is allowed")]
54 IdenticalMetadataKeyNotAllowed(String),
55 #[error("metadata key name invalid `{0}`. try with prefix `x-{0}`")]
57 MetadataPrefixInvalid(String),
58 #[error("task already completed. cannot modify")]
60 TaskAlreadyCompleted,
61 #[error("task already running")]
63 TaskAlreadyRunning,
64 #[error("task not running")]
66 TaskNotRunning,
67 #[error("task descriptor cant be an empty string")]
69 TaskDescriptorEmpty,
70 #[cfg(feature = "notify")]
72 #[error("notifier result kind is not for a Task")]
73 IncompatibleNotifyKind,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct TimeTrack {
79 pub start_time: DateTime<Local>,
81 pub end_time: Option<DateTime<Local>>,
83 pub annotation: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Task {
91 pub id: Uuid,
93 pub description: String,
95 pub done: bool,
97 pub project: Option<String>,
99 pub tags: Option<Vec<String>>,
101 pub metadata: BTreeMap<String, String>,
104 pub timetracker: Option<Vec<TimeTrack>>,
106}
107
108impl Task {
109 #[cfg(feature = "notify")]
111 pub fn from_notify_event(event: DatabaseFileType, settings: &Settings) -> Result<Task> {
112 match event {
113 DatabaseFileType::Task(uuid) => load_task(&uuid.to_string(), settings),
114 _ => bail!(TaskError::IncompatibleNotifyKind)
115 }
116 }
117
118 pub fn loose_match(&self, search: &str) -> bool {
121 if self
122 .description
123 .to_lowercase()
124 .contains(&search.to_lowercase())
125 {
126 return true;
127 }
128
129 if let Some(project) = self.project.clone() {
130 if project.to_lowercase().contains(&search.to_lowercase()) {
131 return true;
132 }
133 }
134
135 if let Some(tags) = self.tags.clone() {
136 for tag in tags {
137 if tag.to_lowercase().contains(&search.to_lowercase()) {
138 return true;
139 }
140 }
141 }
142
143 false
146 }
147
148 pub fn is_running(&self) -> bool {
150 if self.timetracker.is_none() {
151 return false;
152 }
153
154 for timetrack in self.timetracker.as_ref().unwrap() {
155 if timetrack.end_time.is_none() {
156 return true;
157 }
158 }
159
160 false
161 }
162
163 pub fn current_timetrack(&self) -> Option<(usize, TimeTrack)> {
166 for (i, timetrack) in self.timetracker.as_ref().unwrap().iter().enumerate() {
167 if timetrack.end_time.is_none() {
168 return Some((i, timetrack.clone()));
169 }
170 }
171 None
172 }
173
174 pub fn start(&mut self, annotation: &Option<String>) -> Result<TimeTrack> {
176 let tt: TimeTrack;
177 if self.done {
178 bail!(TaskError::TaskAlreadyCompleted);
179 }
180 if !self.is_running() {
181 let timestamp = chrono::offset::Local::now();
182 let mut timetracks: Vec<TimeTrack>;
183 if self.timetracker.is_some() {
184 timetracks = self.timetracker.as_ref().unwrap().to_vec();
185 } else {
186 timetracks = vec![];
187 }
188 tt = TimeTrack {
189 start_time: timestamp,
190 end_time: None,
191 annotation: annotation.clone(),
192 };
193 timetracks.push(tt.clone());
194 self.timetracker = Some(timetracks);
195 } else {
196 bail!(TaskError::TaskAlreadyRunning);
197 }
198
199 Ok(tt)
200 }
201
202 pub fn stop(&mut self) -> Result<Option<TimeTrack>> {
204 if self.done {
205 bail!(TaskError::TaskAlreadyCompleted);
206 }
207
208 let retval: Option<TimeTrack>;
209
210 if self.is_running() {
211 let timestamp = chrono::offset::Local::now();
212 let (pos, mut timetrack) = self.current_timetrack().unwrap();
213 let mut timetracks: Vec<TimeTrack> = self.timetracker.as_ref().unwrap().to_vec();
214 timetrack.end_time = Some(timestamp);
215 _ = timetracks.remove(pos);
216 timetracks.insert(pos, timetrack.clone());
217 self.timetracker = Some(timetracks);
218 retval = Some(timetrack);
219 } else {
220 bail!(TaskError::TaskNotRunning);
221 }
222
223 Ok(retval)
224 }
225
226 pub fn current_runtime(&self) -> Option<Duration> {
229 if !self.is_running() {
230 return None;
231 }
232 let now = chrono::offset::Local::now();
233 let (_, timetrack) = self.current_timetrack().unwrap();
234 let runtime = now - timetrack.start_time;
235
236 Some(runtime)
237 }
238
239 pub fn load_yaml_file_from(task_pathbuf: &PathBuf) -> Result<Self> {
241 let mut file =
242 File::open(task_pathbuf).with_context(|| "while opening task yaml file for reading")?;
243 let mut task_yaml: String = String::new();
244 file.read_to_string(&mut task_yaml)
245 .with_context(|| "while reading task yaml file")?;
246 Task::from_yaml_string(&task_yaml)
247 .with_context(|| "while serializing yaml into task struct")
248 }
249
250 pub fn save_yaml_file_to(&mut self, task_pathbuf: &PathBuf, rotate: &usize) -> Result<()> {
252 if task_pathbuf.is_file() && rotate > &0 {
254 FileRotation::new(&task_pathbuf)
255 .max_old_files(*rotate)
256 .file_extension("yaml".to_string())
257 .rotate()
258 .with_context(|| "while rotating task data file backups")?;
259 }
260 let should_we_block = true;
262 let options = FileOptions::new()
263 .write(true)
264 .create(true)
265 .truncate(true)
266 .append(false);
267 {
268 let mut filelock = FileLock::lock(task_pathbuf, should_we_block, options)
269 .with_context(|| "while opening new task yaml file")?;
270 filelock
271 .file
272 .write_all(
273 self.to_yaml_string()
274 .with_context(|| "while serializing task struct to yaml")?
275 .as_bytes(),
276 )
277 .with_context(|| "while writing to task yaml file")?;
278 filelock
279 .file
280 .flush()
281 .with_context(|| "while flushing os caches to disk")?;
282 filelock
283 .file
284 .sync_all()
285 .with_context(|| "while syncing filesystem metadata")?;
286 }
287
288 Ok(())
289 }
290
291 pub fn mark_as_completed(&mut self) -> Result<()> {
293 if self.is_running() {
294 self.stop().with_context(|| "while stopping a task")?;
296 }
297 if !self.done {
298 self.done = true;
300 let timestamp = chrono::offset::Local::now();
301 self.metadata.insert(
302 String::from("tsk-rs-task-completed-time"),
303 timestamp.to_rfc3339(),
304 );
305 }
306
307 Ok(())
308 }
309
310 pub fn new(description: String) -> Result<Self> {
312 let timestamp = chrono::offset::Local::now();
313 let mut metadata: BTreeMap<String, String> = BTreeMap::new();
314 metadata.insert(
315 String::from("tsk-rs-task-create-time"),
316 timestamp.to_rfc3339(),
317 );
318 let mut task = Task {
319 id: Uuid::new_v4(),
320 description,
321 done: false,
322 project: None,
323 tags: None,
324 metadata,
325 timetracker: None,
326 };
327 let score = task.score().with_context(|| "error during task score insert into metadata")?;
329 task.metadata.insert("tsk-rs-task-score".to_owned(), format!("{}", score));
330
331 Ok(task)
332 }
333
334 pub fn to_yaml_string(&mut self) -> Result<String> {
336 let score = self.score().with_context(|| "error during task score refresh into metadata")?;
338 self.metadata.insert("tsk-rs-task-score".to_owned(), format!("{}", score));
339
340 serde_yaml::to_string(self).with_context(|| "unable to serialize task struct as yaml")
341 }
342
343 pub fn from_yaml_string(input: &str) -> Result<Self> {
345 let mut task: Task = serde_yaml::from_str(input)
346 .with_context(|| "unable to deserialize yaml into task struct")?;
347 let score = task.score().with_context(|| "error during task score refresh into metadata")?;
349 task.metadata.insert("tsk-rs-task-score".to_owned(), format!("{}", score));
350 Ok(task)
351 }
352
353 pub fn from_task_descriptor(input: &String) -> Result<Self> {
357 if input.is_empty() {
358 bail!(TaskError::TaskDescriptorEmpty);
359 }
360 let expressions =
361 parse_task(input.to_string()).with_context(|| "while parsing task descriptor")?;
362
363 let mut description: String = String::new();
364 let mut tags: Vec<String> = vec![];
365 let mut metadata: BTreeMap<String, String> = BTreeMap::new();
366 let mut project: String = String::new();
367
368 for expr in expressions {
369 match expr {
370 Expression::Description(desc) => {
371 if !description.is_empty() {
374 description = format!("{} {}", description, desc);
375 } else {
376 description = desc;
377 }
378 }
379 Expression::Tag(tag) => {
380 let new_tag = tag;
381 if !tags.contains(&new_tag) {
382 tags.push(new_tag);
384 }
385 }
386 Expression::Metadata { key, value } => {
387 let new_key = key.to_ascii_lowercase();
388 if !new_key.starts_with("x-") {
389 bail!(TaskError::MetadataPrefixInvalid(new_key))
390 }
391 if metadata.contains_key(&new_key) {
392 bail!(TaskError::IdenticalMetadataKeyNotAllowed(new_key))
393 }
394 metadata.insert(new_key, value);
396 }
397 Expression::Project(prj) => {
398 if !project.is_empty() {
399 bail!(TaskError::MultipleProjectsNotAllowed);
400 }
401 project = prj
403 }
404 Expression::Priority(prio) => {
405 let prio_str: &str = prio.into();
406 let key = "tsk-rs-task-priority".to_string();
407 if metadata.contains_key(&key) {
408 bail!(TaskError::MultiplePrioritiesNotAllowed)
409 }
410 metadata.insert(key, prio_str.to_string());
411 }
412 Expression::Duedate(datetime) => {
413 let value = datetime.and_local_timezone(Local).unwrap().to_rfc3339();
414 let key = "tsk-rs-task-due-time".to_string();
415 if metadata.contains_key(&key) {
416 bail!(TaskError::MultipleDuedatesNotAllowed)
417 }
418 metadata.insert(key, value);
419 }
420 };
421 }
422
423 let mut ret_tags = None;
424 if !tags.is_empty() {
425 ret_tags = Some(tags)
426 }
427 let mut ret_project = None;
428 if !project.is_empty() {
429 ret_project = Some(project);
430 }
431
432 let timestamp = chrono::offset::Local::now();
433 metadata.insert(
434 String::from("tsk-rs-task-create-time"),
435 timestamp.to_rfc3339(),
436 );
437
438 let mut task = Task {
439 id: Uuid::new_v4(),
440 description,
441 done: false,
442 tags: ret_tags,
443 metadata,
444 project: ret_project,
445 timetracker: None,
446 };
447
448 let score = task.score().with_context(|| "error during task score insert into metadata")?;
450 task.metadata.insert("tsk-rs-task-score".to_owned(), format!("{}", score));
451
452 Ok(task)
453 }
454
455 fn score(&self) -> Result<usize> {
458 let mut score: usize = 0;
460
461 if self.project.is_some() {
462 score += 3;
464 }
465
466 if self.tags.is_some() {
467 score += self.tags.as_ref().unwrap().len() * 2;
469 }
470
471 if self.is_running() {
472 score += 15;
474 }
475
476 if self.timetracker.is_some() {
477 score += self.timetracker.as_ref().unwrap().len();
479 }
480
481 if let Some(priority) = self.metadata.get("tsk-rs-task-priority") {
482 match TaskPriority::from_str(priority)
484 .with_context(|| "while converting task priority to enum")?
485 {
486 TaskPriority::Low => score += 1,
487 TaskPriority::Medium => score += 3,
488 TaskPriority::High => score += 8,
489 TaskPriority::Critical => score += 13,
490 }
491 }
492
493 let timestamp = chrono::offset::Local::now();
494
495 if let Some(duedate_str) = self.metadata.get("tsk-rs-task-due-time") {
496 let duedate = DateTime::from_str(duedate_str)
498 .with_context(|| "while parsing due date string as a datetime")?;
499 let diff = duedate - timestamp;
500
501 match diff.num_days() {
502 n if n < 0 => score += 10,
503 0..=2 => score += 7,
504 3..=5 => score += 3,
505 _ => score += 1,
506 };
507 }
508
509 let create_date = DateTime::from_str(self.metadata.get("tsk-rs-task-create-time").unwrap())
510 .with_context(|| "while reading task creation date")?;
511 let create_diff = timestamp - create_date;
512 score += (create_diff.num_days() as f32 * 0.142_857_15) as usize;
515
516 if let Some(tags) = &self.tags {
518 if tags.contains(&"next".to_string()) {
519 score += 100;
521 }
522
523 if tags.contains(&"hold".to_string()) {
524 if score >= 20 {
526 score -= 20;
527 } else {
528 score = 0;
529 }
530 }
531 }
532
533 Ok(score)
534 }
535
536 pub fn unset_characteristic(
538 &mut self,
539 priority: &bool,
540 due_date: &bool,
541 tags: &Option<Vec<String>>,
542 project: &bool,
543 metadata: &Option<Vec<String>>,
544 ) -> bool {
545 let mut modified = false;
546
547 if *priority {
548 let old_prio = self.metadata.remove("tsk-rs-task-priority");
549 if old_prio.is_some() {
550 modified = true;
551 }
552 }
553
554 if *due_date {
555 let old_duedate = self.metadata.remove("tsk-rs-task-due-time");
556 if old_duedate.is_some() {
557 modified = true;
558 }
559 }
560
561 if let Some(tags) = tags {
562 let mut task_tags = if let Some(task_tags) = self.tags.clone() {
563 task_tags
564 } else {
565 vec![]
566 };
567
568 let mut tags_modified = false;
569 for remove_tag in tags {
570 if let Some(index) = task_tags.iter().position(|r| r == remove_tag) {
571 task_tags.swap_remove(index);
572 tags_modified = true;
573 }
574 }
575
576 if tags_modified {
577 self.tags = Some(task_tags);
578 modified = true;
579 }
580 }
581
582 if *project {
583 self.project = None;
584 modified = true;
585 }
586
587 if let Some(metadata) = metadata {
588 for remove_metadata in metadata {
589 let old = self.metadata.remove(remove_metadata);
590 if old.is_some() {
591 modified = true;
592 }
593 }
594 }
595
596 modified
597 }
598
599 pub fn set_characteristic(
601 &mut self,
602 priority: &Option<TaskPriority>,
603 due_date: &Option<NaiveDateTime>,
604 tags: &Option<Vec<String>>,
605 project: &Option<String>,
606 metadata: &Option<Vec<MetadataKeyValuePair>>,
607 ) -> bool {
608 let mut modified = false;
609
610 if let Some(priority) = priority {
611 let prio_str: &str = priority.into();
612 self.metadata
613 .insert("tsk-rs-task-priority".to_string(), prio_str.to_string());
614
615 modified = true;
616 }
617
618 if let Some(due_date) = due_date {
619 self.metadata.insert(
620 "tsk-rs-task-due-time".to_string(),
621 due_date.and_local_timezone(Local).unwrap().to_rfc3339(),
622 );
623 modified = true;
624 }
625
626 if let Some(tags) = tags {
627 let mut task_tags = if let Some(task_tags) = self.tags.clone() {
628 task_tags
629 } else {
630 vec![]
631 };
632
633 let mut tags_modified = false;
634 for new_tag in tags {
635 if !task_tags.contains(new_tag) {
636 task_tags.push(new_tag.to_string());
637 tags_modified = true;
638 }
639 }
640
641 if tags_modified {
642 self.tags = Some(task_tags);
643 modified = true;
644 }
645 }
646
647 if project.is_some() {
648 self.project = project.clone();
649 modified = true;
650 }
651
652 if let Some(metadata) = metadata {
653 for new_metadata in metadata {
654 self.metadata
655 .insert(new_metadata.key.clone(), new_metadata.value.clone());
656 modified = true;
657 }
658 }
659
660 modified
661 }
662}
663
664pub fn task_pathbuf_from_id(id: &String, settings: &Settings) -> Result<PathBuf> {
666 Ok(settings
667 .task_db_pathbuf()?
668 .join(PathBuf::from(format!("{}.yaml", id))))
669}
670
671pub fn task_pathbuf_from_task(task: &Task, settings: &Settings) -> Result<PathBuf> {
674 task_pathbuf_from_id(&task.id.to_string(), settings)
675}
676
677pub fn load_task(id: &String, settings: &Settings) -> Result<Task> {
679 let task_pathbuf =
680 task_pathbuf_from_id(id, settings).with_context(|| "while building path of the file")?;
681 let task = Task::load_yaml_file_from(&task_pathbuf)
682 .with_context(|| "while loading task yaml file for editing")?;
683 Ok(task)
684}
685
686pub fn save_task(task: &mut Task, settings: &Settings) -> Result<()> {
688 let task_pathbuf = task_pathbuf_from_task(task, settings)?;
689 task.save_yaml_file_to(&task_pathbuf, &settings.data.rotate)
690 .with_context(|| "while saving task yaml file")?;
691 Ok(())
692}
693
694pub fn new_task(descriptor: String, settings: &Settings) -> Result<Task> {
696 let mut task =
697 Task::from_task_descriptor(&descriptor).with_context(|| "while parsing task descriptor")?;
698
699 if let Some(tags) = task.tags.clone() {
701 if tags.contains(&"start".to_string()) && settings.task.starttag {
702 start_task(
703 &task.id.to_string(),
704 &Some("started on creation".to_string()),
705 settings,
706 )?;
707 }
708 }
709
710 save_task(&mut task, settings).with_context(|| "while saving new task")?;
711 Ok(task)
712}
713
714pub fn start_task(id: &String, annotation: &Option<String>, settings: &Settings) -> Result<Task> {
716 let mut task = load_task(id, settings)?;
717 task.start(annotation)
718 .with_context(|| "while starting time tracking")?;
719
720 if settings.task.autorelease {
722 task.unset_characteristic(
723 &false,
724 &false,
725 &Some(vec!["hold".to_string()]),
726 &false,
727 &None,
728 );
729 }
730
731 save_task(&mut task, settings).with_context(|| "while saving started task")?;
732 Ok(task)
733}
734
735pub fn stop_task(id: &String, done: &bool, settings: &Settings) -> Result<Task> {
737 let mut task = load_task(id, settings)?;
738 task.stop()
739 .with_context(|| "while stopping time tracking")?;
740
741 if *done {
742 complete_task(&mut task, settings)?;
743 }
744
745 save_task(&mut task, settings).with_context(|| "while saving stopped task")?;
746
747 Ok(task)
748}
749
750pub fn complete_task(task: &mut Task, settings: &Settings) -> Result<()> {
752 if task.is_running() && settings.task.stopondone {
753 stop_task(&task.id.to_string(), &false, settings)?;
755 }
756
757 if settings.task.clearpsecialtags {
759 task.unset_characteristic(
760 &false,
761 &false,
762 &Some(vec![
763 "start".to_string(),
764 "next".to_string(),
765 "hold".to_string(),
766 ]),
767 &false,
768 &None,
769 );
770 }
771
772 task.mark_as_completed()
773 .with_context(|| "while completing task")?;
774 save_task(task, settings)?;
775
776 Ok(())
777}
778
779pub fn amount_of_tasks(settings: &Settings, include_backups: bool) -> Result<usize> {
781 let mut tasks: usize = 0;
782 let task_pathbuf: PathBuf = task_pathbuf_from_id(&"*".to_string(), settings)?;
783 for task_filename in glob(task_pathbuf.to_str().unwrap())
784 .with_context(|| "while traversing task data directory files")?
785 {
786 if task_filename
788 .as_ref()
789 .unwrap()
790 .file_name()
791 .unwrap()
792 .to_string_lossy()
793 .split('.')
794 .collect::<Vec<_>>()[1]
795 != "yaml"
796 && !include_backups
797 {
798 continue;
799 }
800 tasks += 1;
801 }
802 Ok(tasks)
803}
804
805pub fn list_tasks(
807 search: &Option<String>,
808 include_done: &bool,
809 settings: &Settings,
810) -> Result<Vec<Task>> {
811 let task_pathbuf: PathBuf = task_pathbuf_from_id(&"*".to_string(), settings)?;
812
813 let mut found_tasks: Vec<Task> = vec![];
814 for task_filename in glob(task_pathbuf.to_str().unwrap())
815 .with_context(|| "while traversing task data directory files")?
816 {
817 if task_filename
819 .as_ref()
820 .unwrap()
821 .file_name()
822 .unwrap()
823 .to_string_lossy()
824 .split('.')
825 .collect::<Vec<_>>()[1]
826 != "yaml"
827 {
828 continue;
829 }
830
831 let task = Task::load_yaml_file_from(&task_filename?)
832 .with_context(|| "while loading task from yaml file")?;
833
834 if !task.done || *include_done {
835 if let Some(search) = search {
836 if task.loose_match(search) {
837 found_tasks.push(task);
839 }
840 } else {
841 found_tasks.push(task);
843 }
844 }
845 }
846 found_tasks.sort_by_key(|k| k.score().unwrap());
847 found_tasks.reverse();
848
849 Ok(found_tasks)
850}
851
852#[cfg(test)]
853mod tests {
854 use chrono::{DateTime, Datelike};
855
856 use super::*;
857
858 static FULLTESTCASEINPUT: &str = "some task description here @project-here #taghere #a-second-tag %x-meta=data %x-fuu=bar additional text at the end";
859 static FULLTESTCASEINPUT2: &str = "some task description here PRJ:project-here #taghere TAG:a-second-tag META:x-meta=data %x-fuu=bar DUE:2022-08-16T16:56:00 PRIO:medium and some text at the end";
860 static NOEXPRESSIONSINPUT: &str = "some task description here without expressions";
861 static MULTIPROJECTINPUT: &str = "this has a @project-name, and a @second-project name";
862 static DUPLICATEMETADATAINPUT: &str = "this has %x-fuu=bar definied again with %x-fuu=bar";
863 static INVALIDMETADATAKEY: &str = "here is an %invalid=metadata key";
864 static YAMLTESTINPUT: &str = "id: bd6f75aa-8c8d-47fb-b905-d9f7b15c782d\ndescription: some task description here additional text at the end\ndone: false\nproject: project-here\ntags:\n- taghere\n- a-second-tag\nmetadata:\n x-meta: data\n x-fuu: bar\n x-meta: data\n tsk-rs-task-create-time: 2022-08-06T07:55:26.568460389+00:00\n";
865
866 #[test]
867 fn test_from_yaml() {
868 let task = Task::from_yaml_string(YAMLTESTINPUT).unwrap();
869
870 assert_eq!(task.project, Some(String::from("project-here")));
871 assert_eq!(
872 task.description,
873 "some task description here additional text at the end"
874 );
875 assert_eq!(
876 task.tags,
877 Some(vec![String::from("taghere"), String::from("a-second-tag")])
878 );
879 assert_eq!(task.metadata.get("x-meta"), Some(&String::from("data")));
880 assert_eq!(task.metadata.get("x-fuu"), Some(&String::from("bar")));
881
882 let timestamp =
883 DateTime::parse_from_rfc3339(task.metadata.get("tsk-rs-task-create-time").unwrap())
884 .unwrap();
885 assert_eq!(timestamp.year(), 2022);
886 assert_eq!(timestamp.month(), 8);
887 assert_eq!(timestamp.day(), 6);
888 }
889
890 #[test]
891 fn test_to_yaml() {
892 let mut task = Task::from_task_descriptor(&FULLTESTCASEINPUT.to_string()).unwrap();
893
894 let test_uuid = Uuid::parse_str("bd6f75aa-8c8d-47fb-b905-d9f7b15c782d").unwrap();
896 task.id = test_uuid;
897
898 let yaml_string = task.to_yaml_string().unwrap();
899 assert_eq!(yaml_string,
900 format!("id: {}\ndescription: {}\ndone: false\nproject: {}\ntags:\n- {}\n- {}\nmetadata:\n tsk-rs-task-create-time: {}\n tsk-rs-task-score: '7'\n x-fuu: {}\n x-meta: {}\ntimetracker: null\n",
901 task.id,
902 task.description,
903 task.project.unwrap(),
904 task.tags.clone().unwrap().get(0).unwrap(),
905 task.tags.clone().unwrap().get(1).unwrap(),
906 task.metadata.clone().get("tsk-rs-task-create-time").unwrap(),
907 task.metadata.clone().get("x-fuu").unwrap(),
908 task.metadata.clone().get("x-meta").unwrap(),
909 ));
910 }
911
912 #[test]
913 fn parse_full_testcase() {
914 let task = Task::from_task_descriptor(&FULLTESTCASEINPUT.to_string()).unwrap();
915
916 assert_eq!(task.project, Some(String::from("project-here")));
917 assert_eq!(
918 task.description,
919 "some task description here additional text at the end"
920 );
921 assert_eq!(
922 task.tags,
923 Some(vec![String::from("taghere"), String::from("a-second-tag")])
924 );
925 assert_eq!(task.metadata.get("x-meta"), Some(&String::from("data")));
926 assert_eq!(task.metadata.get("x-fuu"), Some(&String::from("bar")));
927 }
928
929 #[test]
930 fn parse_full_testcase2() {
931 let task = Task::from_task_descriptor(&FULLTESTCASEINPUT2.to_string()).unwrap();
932
933 assert_eq!(task.project, Some(String::from("project-here")));
934 assert_eq!(
935 task.description,
936 "some task description here and some text at the end"
937 );
938 assert_eq!(
939 task.tags,
940 Some(vec![String::from("taghere"), String::from("a-second-tag")])
941 );
942 assert_eq!(task.metadata.get("x-meta"), Some(&String::from("data")));
943 assert_eq!(task.metadata.get("x-fuu"), Some(&String::from("bar")));
944 assert_eq!(
945 task.metadata.get("tsk-rs-task-priority"),
946 Some(&String::from("Medium"))
947 );
948 }
950
951 #[test]
952 fn parse_no_expressions() {
953 let task = Task::from_task_descriptor(&NOEXPRESSIONSINPUT.to_string()).unwrap();
954
955 assert_eq!(task.project, None);
956 assert_eq!(task.description, NOEXPRESSIONSINPUT);
957 assert_eq!(task.tags, None);
958
959 assert!(task.metadata.get("tsk-rs-task-create-time").is_some());
960 }
961
962 #[test]
963 fn reject_multiple_projects() {
964 let task = Task::from_task_descriptor(&MULTIPROJECTINPUT.to_string());
965
966 assert_eq!(
967 task.unwrap_err().downcast::<TaskError>().unwrap(),
968 TaskError::MultipleProjectsNotAllowed
969 );
970 }
971
972 #[test]
973 fn reject_duplicate_metadata() {
974 let task = Task::from_task_descriptor(&DUPLICATEMETADATAINPUT.to_string());
975
976 assert_eq!(
977 task.unwrap_err().downcast::<TaskError>().unwrap(),
978 TaskError::IdenticalMetadataKeyNotAllowed(String::from("x-fuu"))
979 );
980 }
981
982 #[test]
983 fn require_metadata_prefix() {
984 let task = Task::from_task_descriptor(&INVALIDMETADATAKEY.to_string());
985
986 assert_eq!(
987 task.unwrap_err().downcast::<TaskError>().unwrap(),
988 TaskError::MetadataPrefixInvalid(String::from("invalid"))
989 );
990 }
991}
992
993