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
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 TaskManager {
52 const DROP_FILE_DEFAULT_PATH: &str = "task_drop_file.md";
53
54 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 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 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 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 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 if path_index + 1 == path.len() {
185 return Some(Found::Node(node.clone()));
186 }
187 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 path_index + 1 == path.len() {
197 return Some(Found::Node(node.clone()));
199 }
200 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 if path_index + 1 == path.len() {
224 return Some(Found::FileEntry(file_entry.clone()));
225 }
226 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 if path_index + 1 == path.len() {
236 return Some(Found::FileEntry(file_entry.clone()));
237 }
238 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 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() {
259 return Ok(Found::Root(filtered_tasks.clone()));
260 }
261
262 for node in &filtered_tasks.root {
264 if let Some(found) = aux_node(node, path, 0) {
265 return Ok(found); }
267 }
268
269 bail!("Couldn't find entries at path: {:?}", path)
270 }
271
272 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 #[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 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 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 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 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 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}