vault_tasks_core/
lib.rs

1use color_eyre::{eyre::bail, Result};
2use serde::Deserialize;
3
4use std::{collections::HashSet, fmt::Display, path::PathBuf};
5use vault_data::VaultData;
6
7use filter::{filter, Filter};
8use tracing::error;
9use vault_parser::VaultParser;
10
11pub mod filter;
12pub mod parser;
13pub mod sorter;
14pub mod task;
15pub mod vault_data;
16mod vault_parser;
17
18#[derive(Clone, Debug, Deserialize)]
19pub struct TaskMarkerConfig {
20    pub done: char,
21    pub todo: char,
22    pub incomplete: char,
23    pub canceled: char,
24}
25
26// Mostly for tests
27impl Default for TaskMarkerConfig {
28    fn default() -> Self {
29        Self {
30            done: 'x',
31            todo: ' ',
32            incomplete: '/',
33            canceled: '-',
34        }
35    }
36}
37
38#[derive(Clone, Debug, Deserialize)]
39pub struct PrettySymbolsConfig {
40    pub task_done: String,
41    pub task_todo: String,
42    pub task_incomplete: String,
43    pub task_canceled: String,
44    pub due_date: String,
45    pub priority: String,
46    pub today_tag: String,
47}
48impl Default for PrettySymbolsConfig {
49    fn default() -> Self {
50        Self {
51            task_done: String::from("✅"),
52            task_todo: String::from("❌"),
53            task_incomplete: String::from("⏳"),
54            task_canceled: String::from("🚫"),
55            due_date: String::from("📅"),
56            priority: String::from("❗"),
57            today_tag: String::from("☀️"),
58        }
59    }
60}
61#[derive(Clone, Debug, Deserialize, Default)]
62pub struct TasksConfig {
63    #[serde(default)]
64    pub parse_dot_files: bool,
65    #[serde(default)]
66    pub file_tags_propagation: bool,
67    #[serde(default)]
68    pub ignored: Vec<PathBuf>,
69    #[serde(default)]
70    pub indent_length: usize,
71    #[serde(default)]
72    pub use_american_format: bool,
73    #[serde(default)]
74    pub show_relative_due_dates: bool,
75    #[serde(default)]
76    pub vault_path: PathBuf,
77    #[serde(default)]
78    pub explorer_default_search_string: String,
79    #[serde(default)]
80    pub filter_default_search_string: String,
81    #[serde(default)]
82    pub task_state_markers: TaskMarkerConfig,
83    #[serde(default)]
84    pub pretty_symbols: PrettySymbolsConfig,
85}
86
87pub struct TaskManager {
88    pub tasks: VaultData,
89    pub tags: HashSet<String>,
90    pub current_filter: Option<Filter>,
91}
92impl Default for TaskManager {
93    fn default() -> Self {
94        Self {
95            tasks: VaultData::Directory("Empty Vault".to_owned(), vec![]),
96            tags: HashSet::new(),
97            current_filter: None,
98        }
99    }
100}
101impl TaskManager {
102    /// Loads a vault from a `Config` and returns a `TaskManager`.
103    ///
104    /// # Errors
105    ///
106    /// This function will return an error if the vault can't be loaded.
107    pub fn load_from_config(config: &TasksConfig) -> Result<Self> {
108        let mut res = Self::default();
109        res.reload(config)?;
110        Ok(res)
111    }
112
113    /// Reloads the `VaultData` from file system.
114    ///
115    /// # Errors
116    ///
117    /// This function will return an error if the vault can't be parsed, or if tasks can't be fixed (relative dates are replaced by fixed dates for example).
118    pub fn reload(&mut self, config: &TasksConfig) -> Result<()> {
119        let vault_parser = VaultParser::new(config.clone());
120        let tasks = vault_parser.scan_vault()?;
121
122        Self::rewrite_vault_tasks(config, &tasks)
123            .unwrap_or_else(|e| error!("Failed to fix tasks: {e}"));
124
125        let mut tags = HashSet::new();
126        Self::collect_tags(&tasks, &mut tags);
127
128        self.tasks = tasks;
129        self.tags = tags;
130        Ok(())
131    }
132
133    /// Explores the vault and fills a `&mut HashSet<String>` with every tags found.
134    pub fn collect_tags(tasks: &VaultData, tags: &mut HashSet<String>) {
135        match tasks {
136            VaultData::Directory(_, children) | VaultData::Header(_, _, children) => {
137                children.iter().for_each(|c| Self::collect_tags(c, tags));
138            }
139            VaultData::Task(task) => {
140                task.tags.clone().unwrap_or_default().iter().for_each(|t| {
141                    tags.insert(t.clone());
142                });
143                task.subtasks
144                    .iter()
145                    .for_each(|task| Self::collect_tags(&VaultData::Task(task.clone()), tags));
146            }
147        }
148    }
149    /// Follows a path and returns every `VaultData` that are on the target layer, discarding every children.
150    ///
151    /// # Errors
152    ///
153    /// This function will return an error if the path can't be resolved.
154    pub fn get_path_layer_entries(&self, path: &[String]) -> Result<Vec<VaultData>> {
155        Ok(self
156            .get_explorer_entries(path)?
157            .iter()
158            .map(|vd| match vd {
159                VaultData::Directory(name, _) => VaultData::Directory(name.clone(), vec![]),
160                VaultData::Header(level, name, _) => {
161                    VaultData::Header(*level, name.clone(), vec![])
162                }
163                VaultData::Task(t) => {
164                    let mut t = t.clone();
165                    t.subtasks = vec![];
166                    VaultData::Task(t)
167                }
168            })
169            .collect::<Vec<VaultData>>())
170    }
171
172    /// Recursively calls `Task.fix_task_attributes` on every task from the vault.
173    fn rewrite_vault_tasks(config: &TasksConfig, tasks: &VaultData) -> Result<()> {
174        fn explore_tasks_rec(
175            config: &TasksConfig,
176            filename: &mut PathBuf,
177            file_entry: &VaultData,
178        ) -> Result<()> {
179            match file_entry {
180                VaultData::Header(_, _, children) => {
181                    children
182                        .iter()
183                        .try_for_each(|c| explore_tasks_rec(config, filename, c))?;
184                }
185                VaultData::Task(task) => {
186                    task.fix_task_attributes(config, filename)?;
187                    task.subtasks
188                        .iter()
189                        .try_for_each(|t| t.fix_task_attributes(config, filename))?;
190                }
191                VaultData::Directory(dir_name, children) => {
192                    let mut filename = filename.clone();
193                    filename.push(dir_name);
194                    children
195                        .iter()
196                        .try_for_each(|c| explore_tasks_rec(config, &mut filename.clone(), c))?;
197                }
198            }
199            Ok(())
200        }
201        explore_tasks_rec(config, &mut PathBuf::new(), tasks)
202    }
203
204    /// Follows the `selected_header_path` to retrieve the correct `VaultData`.
205    /// Then returns every `VaultData` objects on the same layer.
206    ///
207    /// # Errors
208    /// Will return an error if the vault is empty or the first layer is not a `VaultData::Directory`
209    pub fn get_explorer_entries(&self, selected_header_path: &[String]) -> Result<Vec<VaultData>> {
210        fn aux(
211            file_entry: Vec<VaultData>,
212            selected_header_path: &[String],
213            path_index: usize,
214        ) -> Result<Vec<VaultData>> {
215            if path_index == selected_header_path.len() {
216                Ok(file_entry)
217            } else {
218                for entry in file_entry {
219                    match entry {
220                        VaultData::Directory(name, children)
221                        | VaultData::Header(_, name, children) => {
222                            if name == selected_header_path[path_index] {
223                                return aux(children, selected_header_path, path_index + 1);
224                            }
225                        }
226                        VaultData::Task(task) => {
227                            if task.name == selected_header_path[path_index] {
228                                return aux(
229                                    task.subtasks
230                                        .iter()
231                                        .map(|t| VaultData::Task(t.clone()))
232                                        .collect(),
233                                    selected_header_path,
234                                    path_index + 1,
235                                );
236                            }
237                        }
238                    }
239                }
240                bail!("Couldn't find corresponding entry");
241            }
242        }
243
244        let filtered_tasks = if let Some(task_filter) = &self.current_filter {
245            filter(&self.tasks, task_filter)
246        } else {
247            Some(self.tasks.clone())
248        };
249
250        match filtered_tasks {
251            Some(VaultData::Directory(_, entries)) => aux(entries, selected_header_path, 0),
252            None => bail!("Empty Vault"),
253            _ => {
254                error!("First layer of VaultData was not a Directory");
255                bail!("First layer of VaultData was not a Directory")
256            }
257        }
258    }
259
260    /// Follows the `selected_header_path` to retrieve the correct `VaultData`.
261    /// Returns a vector of `VaultData` with the items to display in TUI, preserving the recursive nature.
262    /// `task_preview_offset`: add offset to return a task instead of one of its subtasks
263    ///
264    /// # Errors
265    /// Will return an error if
266    /// - vault is empty or the first layer is not a `VaultData::Directory`
267    /// - the path can't be resolved in the vault data
268    pub fn get_vault_data_from_path(
269        &self,
270        selected_header_path: &[String],
271        task_preview_offset: usize,
272    ) -> Result<Vec<VaultData>> {
273        fn aux(
274            file_entry: VaultData,
275            selected_header_path: &[String],
276            path_index: usize,
277            task_preview_offset: usize,
278        ) -> Result<Vec<VaultData>> {
279            if path_index == selected_header_path.len() {
280                Ok(vec![file_entry])
281            } else {
282                match file_entry {
283                    VaultData::Directory(name, children) | VaultData::Header(_, name, children) => {
284                        if name == selected_header_path[path_index] {
285                            let mut res = vec![];
286                            for child in children {
287                                if let Ok(mut found) = aux(
288                                    child,
289                                    selected_header_path,
290                                    path_index + 1,
291                                    task_preview_offset,
292                                ) {
293                                    res.append(&mut found);
294                                }
295                            }
296                            Ok(res)
297                        } else {
298                            bail!("Couldn't find corresponding entry");
299                        }
300                    }
301                    VaultData::Task(task) => {
302                        if task.name == selected_header_path[path_index] {
303                            let mut res = vec![];
304
305                            if path_index + task_preview_offset == selected_header_path.len() {
306                                res.push(VaultData::Task(task));
307                            } else {
308                                for child in task.subtasks {
309                                    if let Ok(mut found) = aux(
310                                        VaultData::Task(child),
311                                        selected_header_path,
312                                        path_index + 1,
313                                        task_preview_offset,
314                                    ) {
315                                        res.append(&mut found);
316                                    }
317                                }
318                            }
319                            Ok(res)
320                        } else {
321                            bail!("Couldn't find corresponding entry");
322                        }
323                    }
324                }
325            }
326        }
327
328        let filtered_tasks = if let Some(task_filter) = &self.current_filter {
329            filter(&self.tasks, task_filter)
330        } else {
331            Some(self.tasks.clone())
332        };
333        match filtered_tasks {
334            Some(VaultData::Directory(_, entries)) => {
335                for entry in entries {
336                    if let Ok(res) = aux(entry, selected_header_path, 0, task_preview_offset) {
337                        return Ok(res);
338                    }
339                }
340                error!("Vault was not empty but the entry was not found");
341                bail!("Vault was not empty but the entry was not found");
342            }
343            None => bail!("Empty Vault"),
344            _ => {
345                error!("First layer of VaultData was not a Directory");
346                bail!("Empty Vault")
347            }
348        }
349    }
350
351    /// Whether the path resolves to something that can be entered or not.
352    /// Directories, Headers and Tasks with subtasks can be entered.
353    #[must_use]
354    pub fn can_enter(&self, selected_header_path: &[String]) -> bool {
355        fn aux(file_entry: VaultData, selected_header_path: &[String], path_index: usize) -> bool {
356            if path_index == selected_header_path.len() {
357                true
358            } else {
359                match file_entry {
360                    VaultData::Directory(name, children) | VaultData::Header(_, name, children) => {
361                        if name == selected_header_path[path_index] {
362                            return children
363                                .iter()
364                                .any(|c| aux(c.clone(), selected_header_path, path_index + 1));
365                        }
366                        false
367                    }
368                    VaultData::Task(task) => {
369                        if task.name == selected_header_path[path_index] {
370                            return task.subtasks.iter().any(|t| {
371                                aux(
372                                    VaultData::Task(t.clone()),
373                                    selected_header_path,
374                                    path_index + 1,
375                                )
376                            });
377                        }
378                        false
379                    }
380                }
381            }
382        }
383
384        let filtered_tasks = if let Some(task_filter) = &self.current_filter {
385            filter(&self.tasks, task_filter)
386        } else {
387            return false;
388        };
389        let Some(VaultData::Directory(_, entries)) = filtered_tasks else {
390            return false;
391        };
392        entries
393            .iter()
394            .any(|e| aux(e.clone(), selected_header_path, 0))
395    }
396}
397impl Display for TaskManager {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        write!(f, "{}", self.tasks)?;
400        Ok(())
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use std::collections::HashSet;
407
408    use super::TaskManager;
409
410    use crate::{task::Task, vault_data::VaultData};
411
412    #[test]
413    fn test_get_vault_data() {
414        let expected_tasks = vec![
415            VaultData::Task(Task {
416                name: "test".to_string(),
417                line_number: 8,
418                description: Some("test\ndesc".to_string()),
419                ..Default::default()
420            }),
421            VaultData::Task(Task {
422                name: "test".to_string(),
423                line_number: 8,
424                description: Some("test\ndesc".to_string()),
425                ..Default::default()
426            }),
427            VaultData::Task(Task {
428                name: "test".to_string(),
429                line_number: 8,
430                description: Some("test\ndesc".to_string()),
431                ..Default::default()
432            }),
433        ];
434        let expected_header = VaultData::Header(3, "3".to_string(), expected_tasks.clone());
435        let input = VaultData::Directory(
436            "test".to_owned(),
437            vec![VaultData::Header(
438                0,
439                "Test".to_string(),
440                vec![
441                    VaultData::Header(
442                        1,
443                        "1".to_string(),
444                        vec![VaultData::Header(
445                            2,
446                            "2".to_string(),
447                            vec![expected_header.clone()],
448                        )],
449                    ),
450                    VaultData::Header(
451                        1,
452                        "1.2".to_string(),
453                        vec![
454                            VaultData::Header(3, "3".to_string(), vec![]),
455                            VaultData::Header(
456                                2,
457                                "4".to_string(),
458                                vec![VaultData::Task(Task {
459                                    name: "test".to_string(),
460                                    line_number: 8,
461                                    description: Some("test\ndesc".to_string()),
462                                    ..Default::default()
463                                })],
464                            ),
465                        ],
466                    ),
467                ],
468            )],
469        );
470
471        let task_mgr = TaskManager {
472            tasks: input,
473            tags: HashSet::new(),
474            ..Default::default()
475        };
476
477        let path = vec![String::from("Test"), String::from("1"), String::from("2")];
478        let res = task_mgr.get_vault_data_from_path(&path, 0).unwrap();
479        assert_eq!(vec![expected_header], res);
480
481        let path = vec![
482            String::from("Test"),
483            String::from("1"),
484            String::from("2"),
485            String::from("3"),
486        ];
487        let res = task_mgr.get_vault_data_from_path(&path, 0).unwrap();
488        assert_eq!(expected_tasks, res);
489    }
490}