tsk_rs/
task.rs

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/// Available priorities for a task
27/// Each priority level has an different effect to the overall urgency level calculations
28#[derive(EnumString, IntoStaticStr, clap::ValueEnum, Clone, Eq, PartialEq, Debug)]
29pub enum TaskPriority {
30    /// Low priority
31    Low,
32    /// Medium priority
33    Medium,
34    /// High priority
35    High,
36    /// Critical priority
37    Critical,
38}
39
40/// Errors that can occure when working with a task and its metadata
41#[derive(Error, Debug, PartialEq, Eq)]
42pub enum TaskError {
43    /// Multiple projects were defined in the task descriptor. Not allowed.
44    #[error("only one project identifier allowed")]
45    MultipleProjectsNotAllowed,
46    /// Multiple priorities were defined in the task description. Not allowed.
47    #[error("only one priority identifier allowed")]
48    MultiplePrioritiesNotAllowed,
49    /// Multiple due dates were defined in the task descriptor. Not allowed.
50    #[error("only one due date identifier allowed")]
51    MultipleDuedatesNotAllowed,
52    /// Multiple metadata pairs with same key was defined in the task descriptor. Not allowed.
53    #[error("only one instance of metadata key `{0}` is allowed")]
54    IdenticalMetadataKeyNotAllowed(String),
55    /// Metadata key defined in the task descriptor is malformed or was not prefixed with "x-".
56    #[error("metadata key name invalid `{0}`. try with prefix `x-{0}`")]
57    MetadataPrefixInvalid(String),
58    /// Task was already marked to be completed and thus it cant be modified.
59    #[error("task already completed. cannot modify")]
60    TaskAlreadyCompleted,
61    /// Task is already running
62    #[error("task already running")]
63    TaskAlreadyRunning,
64    /// Task is not running
65    #[error("task not running")]
66    TaskNotRunning,
67    /// Task descriptor was empty
68    #[error("task descriptor cant be an empty string")]
69    TaskDescriptorEmpty,
70    /// Conversion error from notify event kind. Needs to be Task for Task.
71    #[cfg(feature = "notify")]
72    #[error("notifier result kind is not for a Task")]
73    IncompatibleNotifyKind,
74}
75
76/// Time track entry holds information about a span of time while the task was/is being worked on.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct TimeTrack {
79    /// Local timestamp for the moment in time when the time tracking was started
80    pub start_time: DateTime<Local>,
81    /// Local timestamp for the moment in time when the time tracking ended
82    pub end_time: Option<DateTime<Local>>,
83    /// Optional annotation or a description for the time span e.g what was done while working on
84    /// the task?
85    pub annotation: Option<String>,
86}
87
88/// Task data abstraction as a Rust struct
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Task {
91    /// Unique identifier for the task
92    pub id: Uuid,
93    /// Description or a title of the task
94    pub description: String,
95    /// Is the task completed or not
96    pub done: bool,
97    /// To which project (if any) is this task part of?
98    pub project: Option<String>,
99    /// Tags (if any) for the task that can be used to group several tasks of some kind
100    pub tags: Option<Vec<String>>,
101    /// Key, value pairs holding either user added metadata fields prepended with the "x-" string
102    /// or task management internal metadata.
103    pub metadata: BTreeMap<String, String>,
104    /// List of optional [TimeTrack] entries.
105    pub timetracker: Option<Vec<TimeTrack>>,
106}
107
108impl Task {
109    /// Instantiate task by loading it from disk based on notifier event
110    #[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    /// Search the task with a string and try to match it to available information. Return true if
119    /// the task matches.
120    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        // @TODO: match to metadata keys/values
144
145        false
146    }
147
148    /// Returns true if the task is running
149    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    /// Returns current [TimeTrack] entry for the task if one is running. Returns None if time
164    /// tracking is not active.
165    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    /// Start time tracking for the task and return the [TimeTrack] entry
175    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    /// Stop time tracking for the task. Return the [TimeTrack] entry that was concluded.
203    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    /// Return the runtime (delta of start timestamp of [TimeTrack] and current timestamp) of a
227    /// running task.
228    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    /// Load task YAML formatted file from the disk
240    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    /// Save task as YAML formatted file to the disk
251    pub fn save_yaml_file_to(&mut self, task_pathbuf: &PathBuf, rotate: &usize) -> Result<()> {
252        // rotate existing file with same name if present
253        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        // save file by locking
261        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    /// Mark this task as done
292    pub fn mark_as_completed(&mut self) -> Result<()> {
293        if self.is_running() {
294            // if the task is running stop the current timetrack first to cleanup properly
295            self.stop().with_context(|| "while stopping a task")?;
296        }
297        if !self.done {
298            // only mark as done and add metadata if the task is not done yet. this keeps original task-completed-time intact
299            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    /// Create a new task with description only
311    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        // Calculate the score into metadata
328        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    /// Serialize the task as YAML string
335    pub fn to_yaml_string(&mut self) -> Result<String> {
336        // Calculate the score into metadata
337        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    /// Deserialize the task from YAML string
344    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        // Recalculate the score into metadata
348        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    /// Create a new task from task descriptor string
354    ///
355    /// Example: `This is a prj:Project task that has to be done. due:2022-08-01T16:00:00 prio:low meta:x-fuu=bar tag:some tag:tags tag:can tag:be tag:added`
356    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                    // always extend the existing desctiption text with additional
372                    //  text that is found later on
373                    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                        // add the tag only if it is not already added (drop duplicates silently)
383                        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                    // add metadata key => value pair to map
395                    metadata.insert(new_key, value);
396                }
397                Expression::Project(prj) => {
398                    if !project.is_empty() {
399                        bail!(TaskError::MultipleProjectsNotAllowed);
400                    }
401                    // set project
402                    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        // Calculate the score into metadata
449        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    /// Calculate the score for the task than can be used to compare urgencies of seperate tasks
456    /// and giving a priority.
457    fn score(&self) -> Result<usize> {
458        // the more "fleshed out" the task is the more higher score it should get
459        let mut score: usize = 0;
460
461        if self.project.is_some() {
462            // project is valued at 3 points
463            score += 3;
464        }
465
466        if self.tags.is_some() {
467            // each hashtag is valued at two (2) points
468            score += self.tags.as_ref().unwrap().len() * 2;
469        }
470
471        if self.is_running() {
472            // if task is running it gains 15 points
473            score += 15;
474        }
475
476        if self.timetracker.is_some() {
477            // each timetracker entry grants 1 point
478            score += self.timetracker.as_ref().unwrap().len();
479        }
480
481        if let Some(priority) = self.metadata.get("tsk-rs-task-priority") {
482            // priorities have different weights in the score
483            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            // if due date is present then WHEN has a different score
497            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        // as the task gets older each day gives 0.14285715 worth of weight to score. this is rounded when
513        //  returned as usize, but this means that every seven days grants one point
514        score += (create_diff.num_days() as f32 * 0.142_857_15) as usize;
515
516        // special tags (applied last) reduce the or add to the score
517        if let Some(tags) = &self.tags {
518            if tags.contains(&"next".to_string()) {
519                // just like in taskwarrior special tag "next" gives a huge boost
520                score += 100;
521            }
522
523            if tags.contains(&"hold".to_string()) {
524                // hold will reduce score
525                if score >= 20 {
526                    score -= 20;
527                } else {
528                    score = 0;
529                }
530            }
531        }
532
533        Ok(score)
534    }
535
536    /// Remove task characteristics
537    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    /// Set task characteristics
600    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
664/// Construct a taskbuf that points to YAML file on disk where filename is the id
665pub 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
671/// Construct a taskbuf that points to YAML file on disk where the id is pulled from [Task]
672/// metadata
673pub fn task_pathbuf_from_task(task: &Task, settings: &Settings) -> Result<PathBuf> {
674    task_pathbuf_from_id(&task.id.to_string(), settings)
675}
676
677/// Load task from file, identified by id
678pub 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
686/// Save task to disk, identified by the id in its metadata
687pub 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
694/// Create a new task
695pub 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    // once the task file has been created check for special tags that should take immediate action
700    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
714/// Start tracking the task, load & save the file on disk
715pub 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 special tag (hold) is present then release the hold by modifying tags.
721    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
735/// Stop tracking the task, load & save the file on disk
736pub 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
750/// Mark the task completed, load & save the file on disk
751pub fn complete_task(task: &mut Task, settings: &Settings) -> Result<()> {
752    if task.is_running() && settings.task.stopondone {
753        // task is running, so first stop it
754        stop_task(&task.id.to_string(), &false, settings)?;
755    }
756
757    // remove special tags when task is marked completed
758    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
779/// Load all tasks and return the sum of tasks.
780pub 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 the filename is u-u-i-d.3.yaml for example it is a backup file and should be disregarded
787        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
805/// List all tasks that match an optional search criteria
806pub 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 the filename is u-u-i-d.3.yaml for example it is a backup file and should be disregarded
818        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                    // a part of key information matches search term, so the task is included
838                    found_tasks.push(task);
839                }
840            } else {
841                // search term is empty so everything matches
842                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        // for testing we need to know the UUID so create a new one and override autoassigned one
895        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        //assert_eq!(task.metadata.get("tsk-rs-task-due-time"), );
949    }
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// eof