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
25pub 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![] }, tags: HashSet::new(),
39 current_filter: None,
40 config: TasksConfig::default(),
41 }
42 }
43}
44#[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 #[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 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 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 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 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 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 if path_index + 1 == path.len() {
259 return Some(Found::Node(node.clone()));
260 }
261 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 path_index + 1 == path.len() {
271 return Some(Found::Node(node.clone()));
273 }
274 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 if path_index + 1 == path.len() {
298 return Some(Found::FileEntry(file_entry.clone()));
299 }
300 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 if path_index + 1 == path.len() {
310 return Some(Found::FileEntry(file_entry.clone()));
311 }
312 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 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() {
333 return Ok(Found::Root(filtered_tasks.clone()));
334 }
335
336 for node in &filtered_tasks.root {
338 if let Some(found) = aux_node(node, path, 0) {
339 return Ok(found); }
341 }
342
343 bail!("Couldn't find entries at path: {:?}", path)
344 }
345
346 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 #[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 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 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 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 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 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}