Skip to main content

vault_tasks_core/
lib.rs

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