Skip to main content

vault_tasks_core/
lib.rs

1use color_eyre::{Result, eyre::bail};
2
3use std::{collections::HashSet, fmt::Display};
4use vault_data::Vaults;
5
6use filter::Filter;
7use tracing::{error, warn};
8use vault_parser::VaultParser;
9
10use crate::{
11    config::TasksConfig,
12    vault_data::{FileEntryNode, VaultNode},
13};
14
15pub mod config;
16pub mod date;
17pub mod filter;
18pub mod logging;
19pub mod parser;
20pub mod sorter;
21pub mod task;
22pub mod vault_data;
23mod vault_parser;
24
25// Re-export logging functions for easier access
26pub use logging::init as init_logging;
27
28pub struct TaskManager {
29    pub tasks_refactored: Vaults,
30    config: TasksConfig,
31    pub tags: HashSet<String>,
32    pub current_filter: Option<Filter>,
33}
34impl Default for TaskManager {
35    fn default() -> Self {
36        Self {
37            tasks_refactored: Vaults { root: vec![] }, // TODO: will replace tasks eventually
38            tags: HashSet::new(),
39            current_filter: None,
40            config: TasksConfig::default(),
41        }
42    }
43}
44// Helper enum to unify return types
45#[derive(Clone, Debug)]
46pub enum Found {
47    Root(Vaults),
48    Node(VaultNode),
49    FileEntry(FileEntryNode),
50}
51impl TaskManager {
52    const DROP_FILE_DEFAULT_PATH: &str = "task_drop_file.md";
53
54    /// Loads a vault from a `Config` and returns a `TaskManager`.
55    ///
56    /// # Errors
57    ///
58    /// This function will return an error if the vault can't be loaded.
59    pub fn load_from_config(config: &TasksConfig) -> Result<Self> {
60        let mut res = Self::default();
61        res.reload(config)?;
62        Ok(res)
63    }
64
65    /// Reloads the `VaultData` from file system.
66    ///
67    /// # Errors
68    ///
69    /// 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).
70    pub fn reload(&mut self, config: &TasksConfig) -> Result<()> {
71        self.config = config.clone();
72        if self
73            .config
74            .core
75            .vault_path
76            .to_str()
77            .is_some_and(str::is_empty)
78        {
79            bail!( "No vault path provided (use `--vault-path <PATH>`) and no default path set in config file".to_string(), );
80        }
81        if !self.config.core.vault_path.exists() && !cfg!(test) {
82            bail!(
83                "Vault path does not exist: {:?}",
84                self.config.core.vault_path
85            );
86        }
87
88        let vault_parser = VaultParser::new(config.clone());
89        let tasks = vault_parser.scan_vault()?;
90
91        self.tasks_refactored = tasks;
92
93        Self::rewrite_vault_tasks(config, &self.tasks_refactored)
94            .unwrap_or_else(|e| error!("Failed to fix tasks: {e}"));
95
96        let mut tags = HashSet::new();
97        Self::collect_tags(&self.tasks_refactored, &mut tags);
98        self.tags = tags;
99        Ok(())
100    }
101    /// Explores every `NewFileEntry` from the vault and applies the given function `f` on it.
102    pub fn map_file_entries(
103        tasks: &Vaults,
104        f: &mut impl FnMut(&FileEntryNode) -> FileEntryNode,
105    ) -> Vaults {
106        fn explore_nodes(
107            node: &VaultNode,
108            f: &mut impl FnMut(&FileEntryNode) -> FileEntryNode,
109        ) -> VaultNode {
110            match node.clone() {
111                VaultNode::Vault {
112                    name,
113                    path,
114                    content,
115                } => VaultNode::Vault {
116                    name,
117                    path,
118                    content: content.iter().map(|v| explore_nodes(v, f)).collect(),
119                },
120                VaultNode::Directory {
121                    name,
122                    path,
123                    content,
124                } => VaultNode::Directory {
125                    name,
126                    path,
127                    content: content.iter().map(|v| explore_nodes(v, f)).collect(),
128                },
129                VaultNode::File {
130                    name,
131                    path,
132                    content,
133                } => VaultNode::File {
134                    name,
135                    path,
136                    content: content.iter().map(f).collect(),
137                },
138            }
139        }
140        let new_root = tasks.root.iter().map(|n| explore_nodes(n, f)).collect();
141        Vaults { root: new_root }
142    }
143
144    /// Explores the vault and fills a `&mut HashSet<String>` with every tags found.
145    pub fn collect_tags(tasks: &Vaults, tags: &mut HashSet<String>) {
146        fn gather_tags_from_file_entry(file_entry: &FileEntryNode, tags: &mut HashSet<String>) {
147            match file_entry {
148                FileEntryNode::Task(task) => {
149                    task.tags.clone().unwrap_or_default().iter().for_each(|t| {
150                        tags.insert(t.clone());
151                    });
152                    task.subtasks.iter().for_each(|subtask| {
153                        gather_tags_from_file_entry(&FileEntryNode::Task(subtask.clone()), tags);
154                    });
155                }
156                FileEntryNode::Header { content, .. } => {
157                    for c in content {
158                        gather_tags_from_file_entry(c, tags);
159                    }
160                }
161            }
162        }
163        Self::map_file_entries(tasks, &mut |file_entry: &FileEntryNode| {
164            gather_tags_from_file_entry(file_entry, tags);
165            file_entry.clone()
166        });
167    }
168
169    /// Follows the `path` to retrieve the correct `VaultData`.
170    ///
171    /// # Errors
172    /// Will return an error if the vault is empty or the path cannot be resolved
173    pub fn resolve_path(&self, path: &[String]) -> Result<Found> {
174        fn aux_node(node: &VaultNode, path: &[String], path_index: usize) -> Option<Found> {
175            if path_index == path.len() {
176                return Some(Found::Node(node.clone()));
177            }
178
179            match node {
180                VaultNode::Vault { name, content, .. }
181                | VaultNode::Directory { name, content, .. } => {
182                    if *name == path[path_index] {
183                        // Check if we're at the end of the path
184                        if path_index + 1 == path.len() {
185                            return Some(Found::Node(node.clone()));
186                        }
187                        // Otherwise, continue recursing into children
188                        return content
189                            .iter()
190                            .find_map(|child| aux_node(child, path, path_index + 1));
191                    }
192                }
193                VaultNode::File { name, content, .. } => {
194                    if *name == path[path_index] {
195                        // If we're at the end of the path, return file entries as nodes
196                        if path_index + 1 == path.len() {
197                            // Wrap each file entry in a temporary file node for conversion
198                            return Some(Found::Node(node.clone()));
199                        }
200                        // Navigate into file entries
201                        return content
202                            .iter()
203                            .find_map(|entry| aux_file_entry(entry, path, path_index + 1));
204                    }
205                }
206            }
207            None
208        }
209
210        fn aux_file_entry(
211            file_entry: &FileEntryNode,
212            path: &[String],
213            path_index: usize,
214        ) -> Option<Found> {
215            if path_index == path.len() {
216                return Some(Found::FileEntry(file_entry.clone()));
217            }
218
219            match file_entry {
220                FileEntryNode::Header { name, content, .. } => {
221                    if *name == path[path_index] {
222                        // Check if we're at the end of the path
223                        if path_index + 1 == path.len() {
224                            return Some(Found::FileEntry(file_entry.clone()));
225                        }
226                        // Otherwise, continue recursing into children
227                        return content
228                            .iter()
229                            .find_map(|child| aux_file_entry(child, path, path_index + 1));
230                    }
231                }
232                FileEntryNode::Task(task) => {
233                    if task.name == path[path_index] {
234                        // Check if we're at the end of the path
235                        if path_index + 1 == path.len() {
236                            return Some(Found::FileEntry(file_entry.clone()));
237                        }
238                        // Otherwise, continue recursing into subtasks
239                        let subtask_entries: Vec<FileEntryNode> = task
240                            .subtasks
241                            .iter()
242                            .map(|t| FileEntryNode::Task(t.clone()))
243                            .collect();
244                        return subtask_entries
245                            .iter()
246                            .find_map(|st| aux_file_entry(st, path, path_index + 1));
247                    }
248                }
249            }
250            None
251        }
252        // apply filter
253        let Some(filtered_tasks) = filter::filter(&self.tasks_refactored, &self.current_filter)
254        else {
255            bail!("No tasks found after applying filter");
256        };
257        // If path is empty, return all root nodes
258        if path.is_empty() {
259            return Ok(Found::Root(filtered_tasks.clone()));
260        }
261
262        // Try to find entries in each vault root node
263        for node in &filtered_tasks.root {
264            if let Some(found) = aux_node(node, path, 0) {
265                return Ok(found); // path only resolves to one node
266            }
267        }
268
269        bail!("Couldn't find entries at path: {:?}", path)
270    }
271
272    /// Recursively calls `Task.fix_task_attributes` on every task from the vault.
273    fn rewrite_vault_tasks(config: &TasksConfig, tasks: &Vaults) -> Result<()> {
274        fn explore_node(node: &VaultNode, config: &TasksConfig) -> Result<()> {
275            match node {
276                VaultNode::Vault { content, .. } | VaultNode::Directory { content, .. } => {
277                    for c in content {
278                        explore_node(c, config)?;
279                    }
280                }
281                VaultNode::File {
282                    name: _, content, ..
283                } => {
284                    for file_entry in content {
285                        match file_entry {
286                            FileEntryNode::Header { content, .. } => {
287                                for c in content {
288                                    explore_file_entry(c, config)?;
289                                }
290                            }
291                            FileEntryNode::Task(_) => {
292                                explore_file_entry(file_entry, config)?;
293                            }
294                        }
295                    }
296                }
297            }
298            Ok(())
299        }
300        fn explore_file_entry(file_entry: &FileEntryNode, config: &TasksConfig) -> Result<()> {
301            match file_entry {
302                FileEntryNode::Header { content, .. } => {
303                    for c in content {
304                        explore_file_entry(c, config)?;
305                    }
306                }
307                FileEntryNode::Task(task) => {
308                    task.fix_task_attributes(config)?;
309                    for t in &task.subtasks {
310                        t.fix_task_attributes(config)?;
311                    }
312                }
313            }
314            Ok(())
315        }
316
317        tasks
318            .root
319            .iter()
320            .try_for_each(|node| explore_node(node, config))?;
321        Ok(())
322    }
323
324    /// Whether the path resolves to something that can be entered or not.
325    /// Directories, Headers and Tasks with subtasks can be entered.
326    #[must_use]
327    pub fn can_enter(&mut self, selected_header_path: &[String]) -> bool {
328        fn aux_node(node: &VaultNode, selected_header_path: &[String], path_index: usize) -> bool {
329            if path_index == selected_header_path.len() {
330                true
331            } else {
332                match node {
333                    VaultNode::Vault { name, content, .. }
334                    | VaultNode::Directory { name, content, .. } => {
335                        if *name == selected_header_path[path_index] {
336                            return content
337                                .iter()
338                                .any(|c| aux_node(c, selected_header_path, path_index + 1));
339                        }
340                    }
341                    VaultNode::File { name, content, .. } => {
342                        if *name == selected_header_path[path_index] {
343                            return content
344                                .iter()
345                                .any(|c| aux_file_entry(c, selected_header_path, path_index + 1));
346                        }
347                    }
348                }
349                false
350            }
351        }
352
353        fn aux_file_entry(
354            file_entry: &FileEntryNode,
355            selected_header_path: &[String],
356            path_index: usize,
357        ) -> bool {
358            if path_index == selected_header_path.len() {
359                true
360            } else {
361                match file_entry {
362                    FileEntryNode::Header { name, content, .. } => {
363                        if *name == selected_header_path[path_index] {
364                            return content
365                                .iter()
366                                .any(|c| aux_file_entry(c, selected_header_path, path_index + 1));
367                        }
368                        false
369                    }
370                    FileEntryNode::Task(task) => {
371                        if task.name == selected_header_path[path_index] {
372                            return task.subtasks.iter().any(|t| {
373                                aux_file_entry(
374                                    &FileEntryNode::Task(t.clone()),
375                                    selected_header_path,
376                                    path_index + 1,
377                                )
378                            });
379                        }
380                        false
381                    }
382                }
383            }
384        }
385
386        self.tasks_refactored
387            .root
388            .iter()
389            .any(|node| aux_node(node, selected_header_path, 0))
390    }
391    /// Adds a new task to the vault from a raw task input string.
392    /// Task will be added to the drop file configured in the `TasksConfig` or to a default drop file if none is configured.
393    /// # Errors
394    /// Will return an error if the task cannot be parsed or if it cannot be written to the vault.
395    pub fn add_task(&mut self, mut task_input: &str, filename_opt: Option<String>) -> Result<()> {
396        let path = filename_opt
397            .map(|filename| self.config.core.vault_path.join(filename))
398            .or_else(|| self.config.core.tasks_drop_file.clone())
399            .unwrap_or_else(|| {
400                warn!(
401                    "No drop file configured, using default drop filename: {}",
402                    Self::DROP_FILE_DEFAULT_PATH
403                );
404                self.config
405                    .core
406                    .vault_path
407                    .join(Self::DROP_FILE_DEFAULT_PATH)
408            });
409        match parser::task::parse_task(&mut task_input, &path, &self.config) {
410            Ok(task) => {
411                task.fix_task_attributes(&self.config)?;
412                self.reload(&self.config.clone())
413            }
414            Err(e) => bail!("Failed to parse task input: {e}"),
415        }
416    }
417}
418impl Display for TaskManager {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        write!(f, "{}", self.tasks_refactored)?;
421        Ok(())
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use std::collections::HashSet;
428
429    use super::TaskManager;
430
431    use crate::{
432        Found,
433        task::Task,
434        vault_data::{FileEntryNode, VaultNode, Vaults},
435    };
436
437    #[test]
438    fn test_get_vault_data() {
439        use std::path::Path;
440
441        // Create tasks that will be in a header
442        let task1 = Task {
443            name: "test task 1".to_string(),
444            line_number: Some(8),
445            description: Some("test\ndesc".to_string()),
446            ..Default::default()
447        };
448        let task2 = Task {
449            name: "test task 2".to_string(),
450            line_number: Some(9),
451            description: Some("another desc".to_string()),
452            ..Default::default()
453        };
454        let task3 = Task {
455            name: "test task 3".to_string(),
456            line_number: Some(10),
457            ..Default::default()
458        };
459
460        // Build a vault structure: Vault -> Directory -> File -> Headers -> Tasks
461        let file_content = vec![
462            FileEntryNode::Header {
463                name: "1".to_string(),
464                heading_level: 1,
465                content: vec![FileEntryNode::Header {
466                    name: "2".to_string(),
467                    heading_level: 2,
468                    content: vec![],
469                }],
470            },
471            FileEntryNode::Header {
472                name: "1.2".to_string(),
473                heading_level: 1,
474                content: vec![
475                    FileEntryNode::Header {
476                        name: "3".to_string(),
477                        heading_level: 3,
478                        content: vec![],
479                    },
480                    FileEntryNode::Header {
481                        name: "4".to_string(),
482                        heading_level: 2,
483                        content: vec![
484                            FileEntryNode::Task(task1.clone()),
485                            FileEntryNode::Task(task2.clone()),
486                            FileEntryNode::Task(task3.clone()),
487                        ],
488                    },
489                ],
490            },
491        ];
492
493        let test_file = VaultNode::File {
494            name: "Test".to_string(),
495            path: Path::new("test/Test.md").into(),
496            content: file_content,
497        };
498
499        let test_directory = VaultNode::Directory {
500            name: "test".to_string(),
501            path: Path::new("test").into(),
502            content: vec![test_file],
503        };
504
505        let vault_data = Vaults {
506            root: vec![test_directory],
507        };
508
509        let task_mgr = TaskManager {
510            tasks_refactored: vault_data,
511            tags: HashSet::new(),
512            ..Default::default()
513        };
514
515        // Test path to empty header "2" - should return the header FileEntry
516        let path = vec![
517            String::from("test"),
518            String::from("Test"),
519            String::from("1"),
520            String::from("2"),
521        ];
522        let found = task_mgr.resolve_path(&path).unwrap();
523        let expected_header = FileEntryNode::Header {
524            name: "2".to_string(),
525            heading_level: 2,
526            content: vec![],
527        };
528        match found {
529            Found::FileEntry(entry) => assert_eq!(expected_header, entry),
530            _ => panic!("Expected FileEntry, got {found:?}"),
531        }
532
533        // Test path to header "4" with tasks
534        let path = vec![
535            String::from("test"),
536            String::from("Test"),
537            String::from("1.2"),
538            String::from("4"),
539        ];
540        let found = task_mgr.resolve_path(&path).unwrap();
541        let expected_header_with_tasks = FileEntryNode::Header {
542            name: "4".to_string(),
543            heading_level: 2,
544            content: vec![
545                FileEntryNode::Task(task1),
546                FileEntryNode::Task(task2),
547                FileEntryNode::Task(task3),
548            ],
549        };
550        match found {
551            Found::FileEntry(entry) => assert_eq!(expected_header_with_tasks, entry),
552            _ => panic!("Expected FileEntry, got {found:?}"),
553        }
554    }
555}